@owlmeans/server-payment 0.1.18-rc.2 → 0.1.18-rc.21
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/README.md +109 -25
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +475 -52
- package/build/actions/index.d.ts +1 -0
- package/build/actions/index.d.ts.map +1 -1
- package/build/actions/index.js +1 -0
- package/build/actions/index.js.map +1 -1
- package/build/actions/resync-subscriptions.d.ts +3 -0
- package/build/actions/resync-subscriptions.d.ts.map +1 -0
- package/build/actions/resync-subscriptions.js +7 -0
- package/build/actions/resync-subscriptions.js.map +1 -0
- package/build/actions/resync.d.ts +1 -0
- package/build/actions/resync.d.ts.map +1 -1
- package/build/actions/resync.js +7 -3
- package/build/actions/resync.js.map +1 -1
- package/build/actions/webhook.d.ts.map +1 -1
- package/build/actions/webhook.js +6 -3
- package/build/actions/webhook.js.map +1 -1
- package/build/config.d.ts +60 -5
- package/build/config.d.ts.map +1 -1
- package/build/config.js +237 -5
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +75 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +94 -5
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/entitlement.d.ts +10 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +69 -0
- package/build/entitlement.js.map +1 -0
- package/build/entrypoints.d.ts +1 -1
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +2 -1
- package/build/entrypoints.js.map +1 -1
- package/build/gate.d.ts +32 -4
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +59 -19
- package/build/gate.js.map +1 -1
- package/build/index.d.ts +18 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +15 -3
- package/build/index.js.map +1 -1
- package/build/limit.d.ts +13 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +47 -0
- package/build/limit.js.map +1 -0
- package/build/model.d.ts +10 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +215 -17
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +9 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +38 -12
- package/build/observer.js.map +1 -1
- package/build/plan.d.ts +22 -0
- package/build/plan.d.ts.map +1 -0
- package/build/plan.js +72 -0
- package/build/plan.js.map +1 -0
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +268 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts +45 -10
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +605 -136
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +31 -0
- package/build/plugins/fx.d.ts.map +1 -0
- package/build/plugins/fx.js +81 -0
- package/build/plugins/fx.js.map +1 -0
- package/build/plugins/portal.d.ts +37 -0
- package/build/plugins/portal.d.ts.map +1 -0
- package/build/plugins/portal.js +265 -0
- package/build/plugins/portal.js.map +1 -0
- package/build/plugins/refunds.d.ts +33 -0
- package/build/plugins/refunds.d.ts.map +1 -0
- package/build/plugins/refunds.js +80 -0
- package/build/plugins/refunds.js.map +1 -0
- package/build/plugins/stripe.d.ts +36 -4
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +492 -97
- package/build/plugins/stripe.js.map +1 -1
- package/build/plugins/webhook-manager.d.ts +46 -0
- package/build/plugins/webhook-manager.d.ts.map +1 -0
- package/build/plugins/webhook-manager.js +231 -0
- package/build/plugins/webhook-manager.js.map +1 -0
- package/build/reconcile.d.ts +15 -0
- package/build/reconcile.d.ts.map +1 -0
- package/build/reconcile.js +88 -0
- package/build/reconcile.js.map +1 -0
- package/build/resource.d.ts +12 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +97 -6
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +28 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +146 -19
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +48 -0
- package/build/subscription.d.ts.map +1 -0
- package/build/subscription.js +176 -0
- package/build/subscription.js.map +1 -0
- package/build/sync.d.ts +28 -1
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +208 -31
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +1115 -36
- package/build/types.d.ts.map +1 -1
- package/build/usage.d.ts +63 -0
- package/build/usage.d.ts.map +1 -0
- package/build/usage.js +363 -0
- package/build/usage.js.map +1 -0
- package/build/utils.d.ts +52 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +69 -7
- package/build/utils.js.map +1 -1
- package/package.json +17 -13
- package/src/actions/index.ts +1 -0
- package/src/actions/resync-subscriptions.ts +10 -0
- package/src/actions/resync.ts +6 -3
- package/src/actions/webhook.ts +5 -3
- package/src/config.ts +264 -8
- package/src/consts.ts +114 -6
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/entitlement.ts +84 -0
- package/src/entrypoints.ts +2 -1
- package/src/gate.ts +88 -21
- package/src/index.ts +24 -3
- package/src/limit.ts +57 -0
- package/src/model.ts +237 -18
- package/src/observer.ts +44 -11
- package/src/plan.ts +89 -0
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +339 -0
- package/src/plugins/events.ts +677 -121
- package/src/plugins/fx.ts +122 -0
- package/src/plugins/portal.ts +306 -0
- package/src/plugins/refunds.ts +108 -0
- package/src/plugins/stripe.ts +581 -96
- package/src/plugins/webhook-manager.ts +270 -0
- package/src/reconcile.ts +103 -0
- package/src/resource.ts +152 -7
- package/src/service.ts +174 -18
- package/src/subscription.ts +231 -0
- package/src/sync.ts +249 -29
- package/src/types.ts +1227 -32
- package/src/usage.ts +453 -0
- package/src/utils.ts +127 -10
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/checkout.spec.ts +184 -83
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +109 -0
- package/tests/entitlement.spec.ts +103 -0
- package/tests/estimate.spec.ts +240 -0
- package/tests/events.spec.ts +356 -0
- package/tests/fake-stripe.ts +972 -0
- package/tests/gate.spec.ts +62 -72
- package/tests/limit-gate.spec.ts +68 -0
- package/tests/portal.spec.ts +200 -0
- package/tests/protocol.spec.ts +23 -6
- package/tests/sync.spec.ts +114 -0
- package/tests/usage.integration.spec.ts +101 -0
- package/tests/usage.spec.ts +171 -0
- package/tests/webhook-manager.spec.ts +152 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import {
|
|
3
|
+
consumerText, oneTimeWithdrawalRefund, PurchaseKind, subscriptionWithdrawalRefund,
|
|
4
|
+
} from '@owlmeans/payment'
|
|
5
|
+
import type { WithdrawalEstimate } from '@owlmeans/payment'
|
|
6
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
7
|
+
import { STRIPE_OWNER_KEY, STRIPE_OWNER_VALUE, STRIPE_PAYGATE_ALIAS } from '../consts.js'
|
|
8
|
+
import { findPlan } from '../plan.js'
|
|
9
|
+
import { compact, errorText, isMissingObject, subscriptions } from '../utils.js'
|
|
10
|
+
import { invoiceEvidenceOf } from './capture.js'
|
|
11
|
+
import { formatDate } from './format.js'
|
|
12
|
+
import { hasEvent, patchPurchase, purchaseRefOf, recordEvent } from './records.js'
|
|
13
|
+
import type {
|
|
14
|
+
ConsumerDeclarationRecord, PaymentSubscriptionRecord, PurchaseRecord, UsageMeter, UsageReading,
|
|
15
|
+
} from '../types.js'
|
|
16
|
+
|
|
17
|
+
const DAY_MS = 86_400_000
|
|
18
|
+
|
|
19
|
+
/** What a withdrawal of one purchase reimburses, as computed at one instant. */
|
|
20
|
+
export interface WithdrawalComputation {
|
|
21
|
+
reading: UsageReading
|
|
22
|
+
/** Gross, tax included — what is refunded. */
|
|
23
|
+
refundMinor: number
|
|
24
|
+
/** Net of tax — the credit note's line amount. */
|
|
25
|
+
netMinor: number
|
|
26
|
+
estimate: WithdrawalEstimate
|
|
27
|
+
/** The units the deduction counts: used after consent, plus settled debt and earlier claw-backs. */
|
|
28
|
+
deducted: number
|
|
29
|
+
/** What the application takes back from the balance: the purchase's units still unused. */
|
|
30
|
+
unitsReturned: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The refund of a withdrawal from one purchase, in the consumer's favour (`@owlmeans/payment`
|
|
35
|
+
* calculators). The meter's deduction is `usedAfter + settled + clawed`: units used after consent
|
|
36
|
+
* (before it the consumer bears no cost), units that paid an earlier overdraft, units already
|
|
37
|
+
* refunded by money. A subscription's `time` components count from its start request; without
|
|
38
|
+
* one nothing is deducted for time.
|
|
39
|
+
*/
|
|
40
|
+
export const computeWithdrawal = async (
|
|
41
|
+
ctx: ApiContext, meter: UsageMeter, purchase: PurchaseRecord, at: Date,
|
|
42
|
+
): Promise<WithdrawalComputation> => {
|
|
43
|
+
const reading = await meter.used(ctx, compact({
|
|
44
|
+
entityId: purchase.entityId, purchase: purchaseRefOf(purchase),
|
|
45
|
+
after: purchase.consentedAt != null ? new Date(purchase.consentedAt) : undefined, at,
|
|
46
|
+
}))
|
|
47
|
+
const deducted = Math.max(0, reading.usedAfter) + Math.max(0, reading.settled ?? 0) + Math.max(0, reading.clawed ?? 0)
|
|
48
|
+
const unitsReturned = Math.max(0, Math.floor(reading.remaining
|
|
49
|
+
?? reading.granted - reading.used - (reading.settled ?? 0) - (reading.clawed ?? 0)))
|
|
50
|
+
const paid = purchase.amountTotalMinor
|
|
51
|
+
const refunded = purchase.refundedMinor ?? 0
|
|
52
|
+
const net = purchase.amountSubtotalMinor
|
|
53
|
+
// The earlier refunds' net share, so the credit note never credits more than is left.
|
|
54
|
+
const refundedNet = paid > 0 ? Math.floor(refunded * net / paid) : 0
|
|
55
|
+
|
|
56
|
+
if (purchase.kind === PurchaseKind.TopUp) {
|
|
57
|
+
const gross = oneTimeWithdrawalRefund({ paidMinor: paid, refundedMinor: refunded, unitsGranted: reading.granted, unitsUsed: deducted })
|
|
58
|
+
const netRefund = oneTimeWithdrawalRefund({ paidMinor: net, refundedMinor: refundedNet, unitsGranted: reading.granted, unitsUsed: deducted })
|
|
59
|
+
const open = Math.max(0, paid - refunded)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
reading, deducted, unitsReturned,
|
|
63
|
+
refundMinor: gross.refundMinor,
|
|
64
|
+
netMinor: netRefund.refundMinor,
|
|
65
|
+
estimate: {
|
|
66
|
+
refundMinor: gross.refundMinor, currency: purchase.currency, timeDeductionMinor: 0,
|
|
67
|
+
unitsDeductionMinor: Math.max(0, open - gross.refundMinor),
|
|
68
|
+
unitsUsed: Math.min(deducted, reading.granted), unitsGranted: reading.granted,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const subscription = purchase.subscriptionId != null
|
|
74
|
+
? await subscriptions(ctx).byExternalId(purchase.subscriptionId, STRIPE_PAYGATE_ALIAS) : null
|
|
75
|
+
const plan = purchase.planSku != null ? await findPlan(ctx, purchase.planSku) : null
|
|
76
|
+
const purchasedAt = new Date(purchase.purchasedAt)
|
|
77
|
+
const periodStart = subscription?.periodStart != null ? new Date(subscription.periodStart) : purchasedAt
|
|
78
|
+
const periodEnd = subscription?.periodEnd != null ? new Date(subscription.periodEnd)
|
|
79
|
+
: new Date(periodStart.getTime() + (plan?.recurring?.interval === 'year' ? 365 : 30) * DAY_MS)
|
|
80
|
+
const result = subscriptionWithdrawalRefund({
|
|
81
|
+
paidMinor: paid, refundedMinor: refunded, netMinor: net,
|
|
82
|
+
components: plan?.withdrawal?.components,
|
|
83
|
+
periodStart, periodEnd,
|
|
84
|
+
servicesRequestedAt: purchase.servicesStartedAt != null ? new Date(purchase.servicesStartedAt) : null,
|
|
85
|
+
withdrawnAt: at,
|
|
86
|
+
units: { granted: reading.granted, used: deducted },
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
reading, deducted, unitsReturned,
|
|
91
|
+
refundMinor: result.refundMinor,
|
|
92
|
+
netMinor: result.refundNetMinor,
|
|
93
|
+
estimate: compact({
|
|
94
|
+
refundMinor: result.refundMinor, currency: purchase.currency, timeDeductionMinor: result.timeDeductionMinor,
|
|
95
|
+
unitsDeductionMinor: result.unitsDeductionMinor, elapsedDays: result.elapsedDays, periodDays: result.periodDays,
|
|
96
|
+
unitsUsed: result.unitsUsed, unitsGranted: result.unitsGranted,
|
|
97
|
+
}) as WithdrawalEstimate,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface WithdrawalExecution {
|
|
102
|
+
/** Every paygate step succeeded (or had nothing to do). */
|
|
103
|
+
ok: boolean
|
|
104
|
+
refundId?: string
|
|
105
|
+
creditNoteId?: string
|
|
106
|
+
refundedMinor: number
|
|
107
|
+
subscriptionCanceled: boolean
|
|
108
|
+
/** A step can never succeed without an operator (no payment to refund). */
|
|
109
|
+
needsReview: boolean
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A Stripe error that says the subscription is already gone. */
|
|
113
|
+
const alreadyCanceled = (error: unknown): boolean => isMissingObject(error)
|
|
114
|
+
|| /canceled subscription|already (been )?cancel/i.test((error as { message?: string } | null)?.message ?? '')
|
|
115
|
+
|
|
116
|
+
/** A refund this withdrawal already made (found by its metadata) — what a retry adopts. */
|
|
117
|
+
const existingRefund = async (stripe: Stripe, paymentIntentId: string, withdrawalId: string): Promise<Stripe.Refund | null> => {
|
|
118
|
+
const { data } = await stripe.refunds.list({ payment_intent: paymentIntentId, limit: 100 })
|
|
119
|
+
|
|
120
|
+
return data.find(refund => refund.metadata?.withdrawalId === withdrawalId && refund.status !== 'failed'
|
|
121
|
+
&& refund.status !== 'canceled') ?? null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A credit note this withdrawal already made — what a retry adopts. */
|
|
125
|
+
const existingCreditNote = async (stripe: Stripe, invoiceId: string, withdrawalId: string): Promise<Stripe.CreditNote | null> => {
|
|
126
|
+
const { data } = await stripe.creditNotes.list({ invoice: invoiceId, limit: 100 })
|
|
127
|
+
|
|
128
|
+
return data.find(note => note.metadata?.withdrawalId === withdrawalId && note.status !== 'void') ?? null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Stripe replays the stored answer of an idempotency key — a failure too — for a day: a retry
|
|
133
|
+
* uses a fresh key (`…:<attempt>`) and first adopts what an earlier attempt may have made.
|
|
134
|
+
*/
|
|
135
|
+
const keyOf = (withdrawalId: string, step: string, attempt: number): Stripe.RequestOptions => ({
|
|
136
|
+
idempotencyKey: attempt > 0 ? `withdrawal:${withdrawalId}:${step}:${attempt}` : `withdrawal:${withdrawalId}:${step}`,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Execute a withdrawal at the paygate, the first attempt of every call under the idempotency key
|
|
141
|
+
* `withdrawal:<id>:<step>` (a retry by `reconcile` adopts what an earlier attempt made):
|
|
142
|
+
*
|
|
143
|
+
* 1. a subscription purchase: `subscriptions.cancel` at once, without proration or a final invoice;
|
|
144
|
+
* 2. an invoice-backed purchase: `creditNotes.preview` of the invoice line at the net refund, then
|
|
145
|
+
* OUR `refunds.create` for the previewed total (metadata `withdrawalId` — the refund webhook then
|
|
146
|
+
* tells observers not to claw back), then `creditNotes.create` linking that refund — the
|
|
147
|
+
* corrective tax document; a failed credit note keeps the plain refund and is recorded;
|
|
148
|
+
* 3. no invoice: a plain proportional refund on the payment intent.
|
|
149
|
+
*
|
|
150
|
+
* Steps that already succeeded (a recorded `ok` event) are skipped. Every step is an event.
|
|
151
|
+
*/
|
|
152
|
+
export const executeWithdrawal = async (
|
|
153
|
+
ctx: ApiContext, stripe: Stripe, declaration: ConsumerDeclarationRecord, purchase: PurchaseRecord,
|
|
154
|
+
computation: { refundMinor: number, netMinor: number }, opts: { attempt?: number } = {},
|
|
155
|
+
): Promise<WithdrawalExecution> => {
|
|
156
|
+
const withdrawalId = declaration.id as string
|
|
157
|
+
const attempt = opts.attempt ?? 0
|
|
158
|
+
const key = (step: string): Stripe.RequestOptions => keyOf(withdrawalId, step, attempt)
|
|
159
|
+
const event = (action: 'refund' | 'credit-note' | 'subscription-cancel', fields: Record<string, unknown>) =>
|
|
160
|
+
recordEvent(ctx, {
|
|
161
|
+
recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action, ok: false, ...fields,
|
|
162
|
+
} as Parameters<typeof recordEvent>[1])
|
|
163
|
+
const outcome: WithdrawalExecution = { ok: true, refundedMinor: 0, subscriptionCanceled: false, needsReview: false }
|
|
164
|
+
|
|
165
|
+
if (purchase.kind === PurchaseKind.Subscription && purchase.subscriptionId != null) {
|
|
166
|
+
if (await hasEvent(ctx, withdrawalId, 'subscription-cancel', undefined, true)) {
|
|
167
|
+
outcome.subscriptionCanceled = true
|
|
168
|
+
} else {
|
|
169
|
+
try {
|
|
170
|
+
await stripe.subscriptions.cancel(purchase.subscriptionId, {
|
|
171
|
+
prorate: false, invoice_now: false, cancellation_details: { comment: `withdrawal:${withdrawalId}` },
|
|
172
|
+
}, key('subscription-cancel'))
|
|
173
|
+
outcome.subscriptionCanceled = true
|
|
174
|
+
await event('subscription-cancel', { ok: true, externalId: purchase.subscriptionId })
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (alreadyCanceled(error)) {
|
|
177
|
+
outcome.subscriptionCanceled = true
|
|
178
|
+
await event('subscription-cancel', { ok: true, externalId: purchase.subscriptionId, detail: '{"already":true}' })
|
|
179
|
+
} else {
|
|
180
|
+
outcome.ok = false
|
|
181
|
+
await event('subscription-cancel', { externalId: purchase.subscriptionId, error: errorText(error) })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (outcome.subscriptionCanceled) {
|
|
185
|
+
const row: PaymentSubscriptionRecord | null = await subscriptions(ctx).byExternalId(purchase.subscriptionId, STRIPE_PAYGATE_ALIAS)
|
|
186
|
+
if (row != null && row.withdrawnAt == null) {
|
|
187
|
+
await subscriptions(ctx).update({ ...row, withdrawnAt: new Date(declaration.receivedAt) })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (await hasEvent(ctx, withdrawalId, 'refund', undefined, true)) {
|
|
194
|
+
return { ...outcome, refundedMinor: declaration.refundMinor ?? computation.refundMinor }
|
|
195
|
+
}
|
|
196
|
+
if (computation.refundMinor <= 0) {
|
|
197
|
+
return outcome
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let paymentIntentId = purchase.paymentIntentId
|
|
201
|
+
let invoiceLineId = purchase.invoiceLineId
|
|
202
|
+
if (purchase.invoiceId != null && (paymentIntentId == null || invoiceLineId == null)) {
|
|
203
|
+
const invoice = await invoiceEvidenceOf(stripe, purchase.invoiceId)
|
|
204
|
+
paymentIntentId = paymentIntentId ?? invoice.paymentIntentId
|
|
205
|
+
invoiceLineId = invoiceLineId ?? invoice.invoiceLineId
|
|
206
|
+
await patchPurchase(ctx, purchase.purchaseId, compact({
|
|
207
|
+
paymentIntentId, invoiceLineId, invoiceNumber: purchase.invoiceNumber ?? invoice.invoiceNumber,
|
|
208
|
+
}))
|
|
209
|
+
}
|
|
210
|
+
if (paymentIntentId == null) {
|
|
211
|
+
await event('refund', { error: 'no-payment-intent', amountMinor: computation.refundMinor, currency: purchase.currency })
|
|
212
|
+
return { ...outcome, ok: false, needsReview: true }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const open = Math.max(0, purchase.amountTotalMinor - (purchase.refundedMinor ?? 0))
|
|
216
|
+
let amount = Math.min(open, computation.refundMinor)
|
|
217
|
+
let lines: Stripe.CreditNoteCreateParams.Line[] | null = null
|
|
218
|
+
if (purchase.invoiceId != null && invoiceLineId != null && computation.netMinor > 0) {
|
|
219
|
+
lines = [{ type: 'invoice_line_item', invoice_line_item: invoiceLineId, amount: computation.netMinor }]
|
|
220
|
+
try {
|
|
221
|
+
const preview = await stripe.creditNotes.preview({ invoice: purchase.invoiceId, lines })
|
|
222
|
+
amount = Math.min(open, preview.total)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
lines = null
|
|
225
|
+
await event('credit-note', { step: 'preview', error: errorText(error) })
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (amount <= 0) {
|
|
229
|
+
return outcome
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let refund: Stripe.Refund
|
|
233
|
+
try {
|
|
234
|
+
refund = (attempt > 0 ? await existingRefund(stripe, paymentIntentId, withdrawalId) : null)
|
|
235
|
+
?? await stripe.refunds.create({
|
|
236
|
+
payment_intent: paymentIntentId, amount, reason: 'requested_by_customer',
|
|
237
|
+
metadata: { [STRIPE_OWNER_KEY]: STRIPE_OWNER_VALUE, withdrawalId, purchaseId: purchase.purchaseId },
|
|
238
|
+
}, key('refund'))
|
|
239
|
+
} catch (error) {
|
|
240
|
+
await event('refund', { error: errorText(error), amountMinor: amount, currency: purchase.currency })
|
|
241
|
+
return { ...outcome, ok: false }
|
|
242
|
+
}
|
|
243
|
+
await event('refund', { ok: true, externalId: refund.id, amountMinor: refund.amount, currency: refund.currency })
|
|
244
|
+
outcome.refundId = refund.id
|
|
245
|
+
outcome.refundedMinor = refund.amount
|
|
246
|
+
|
|
247
|
+
if (lines != null && purchase.invoiceId != null) {
|
|
248
|
+
try {
|
|
249
|
+
const note = await stripe.creditNotes.create({
|
|
250
|
+
invoice: purchase.invoiceId, lines, refund: refund.id,
|
|
251
|
+
memo: consumerText(declaration.language, 'credit-note.memo', {
|
|
252
|
+
contractRef: purchase.contractRef, date: formatDate(new Date(declaration.receivedAt), declaration.language),
|
|
253
|
+
}),
|
|
254
|
+
metadata: { withdrawalId, purchaseId: purchase.purchaseId },
|
|
255
|
+
}, key('credit-note'))
|
|
256
|
+
outcome.creditNoteId = note.id
|
|
257
|
+
await event('credit-note', { ok: true, externalId: note.id, amountMinor: note.total, currency: note.currency })
|
|
258
|
+
} catch (error) {
|
|
259
|
+
// The refund stands; the corrective document is retried by reconcile.
|
|
260
|
+
await event('credit-note', { error: errorText(error), detail: JSON.stringify({ refundId: refund.id }) })
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return outcome
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Retry a credit note that failed after its refund succeeded. */
|
|
268
|
+
export const retryCreditNote = async (
|
|
269
|
+
ctx: ApiContext, stripe: Stripe, declaration: ConsumerDeclarationRecord, purchase: PurchaseRecord,
|
|
270
|
+
netMinor: number, refundId: string, attempt: number,
|
|
271
|
+
): Promise<boolean> => {
|
|
272
|
+
const withdrawalId = declaration.id as string
|
|
273
|
+
let invoiceLineId = purchase.invoiceLineId
|
|
274
|
+
if (invoiceLineId == null && purchase.invoiceId != null) {
|
|
275
|
+
invoiceLineId = (await invoiceEvidenceOf(stripe, purchase.invoiceId)).invoiceLineId
|
|
276
|
+
}
|
|
277
|
+
if (purchase.invoiceId == null || invoiceLineId == null || netMinor <= 0) {
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const note = await existingCreditNote(stripe, purchase.invoiceId, withdrawalId) ?? await stripe.creditNotes.create({
|
|
282
|
+
invoice: purchase.invoiceId,
|
|
283
|
+
lines: [{ type: 'invoice_line_item', invoice_line_item: invoiceLineId, amount: netMinor }],
|
|
284
|
+
refund: refundId,
|
|
285
|
+
memo: consumerText(declaration.language, 'credit-note.memo', {
|
|
286
|
+
contractRef: purchase.contractRef, date: formatDate(new Date(declaration.receivedAt), declaration.language),
|
|
287
|
+
}),
|
|
288
|
+
metadata: { withdrawalId, purchaseId: purchase.purchaseId },
|
|
289
|
+
}, keyOf(withdrawalId, 'credit-note', attempt))
|
|
290
|
+
await recordEvent(ctx, {
|
|
291
|
+
recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action: 'credit-note', ok: true,
|
|
292
|
+
externalId: note.id, amountMinor: note.total, currency: note.currency,
|
|
293
|
+
})
|
|
294
|
+
return true
|
|
295
|
+
} catch (error) {
|
|
296
|
+
await recordEvent(ctx, {
|
|
297
|
+
recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action: 'credit-note', ok: false,
|
|
298
|
+
error: errorText(error), detail: JSON.stringify({ refundId }),
|
|
299
|
+
})
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createService } from '@owlmeans/context'
|
|
2
|
+
import {
|
|
3
|
+
capabilityOf, entitlementViewOf, INTERNAL_PAYGATE, SubscriptionStatus, windowKeyOf,
|
|
4
|
+
} from '@owlmeans/payment'
|
|
5
|
+
import type { EntitlementPlanView, EntitlementView, LimitUsage } from '@owlmeans/payment'
|
|
6
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
7
|
+
import { ENTITLEMENT_SERVICE } from './consts.js'
|
|
8
|
+
import { planRank, resolveEffectivePlan } from './plan.js'
|
|
9
|
+
import {
|
|
10
|
+
consumeLimit, consumptionByRefOf, consumptionOf, limitStateOf, reconcileLedgerCounters, reconcileOccupancyOf, releaseLimit,
|
|
11
|
+
} from './usage.js'
|
|
12
|
+
import { compact, usageCounters } from './utils.js'
|
|
13
|
+
import type { EffectivePlan, EntitlementService } from './types.js'
|
|
14
|
+
|
|
15
|
+
const dateOrUndefined = (value: Date | null | undefined): Date | undefined =>
|
|
16
|
+
value == null ? undefined : new Date(value)
|
|
17
|
+
|
|
18
|
+
/** The plan half of an entitlement view. `subscribedAt` is what a grandfathered promo is measured against. */
|
|
19
|
+
export const planViewOf = (effective: EffectivePlan): EntitlementPlanView => {
|
|
20
|
+
const { plan, subscription, fallback } = effective
|
|
21
|
+
const status = subscription?.status ?? SubscriptionStatus.Active
|
|
22
|
+
|
|
23
|
+
return compact<EntitlementPlanView>({
|
|
24
|
+
sku: plan.sku,
|
|
25
|
+
productSku: plan.productSku,
|
|
26
|
+
title: plan.title,
|
|
27
|
+
rank: planRank(plan),
|
|
28
|
+
free: plan.free === true,
|
|
29
|
+
status,
|
|
30
|
+
paygate: subscription?.paygate ?? INTERNAL_PAYGATE,
|
|
31
|
+
subscriptionId: subscription?.externalId ?? undefined,
|
|
32
|
+
subscribedAt: dateOrUndefined(subscription?.createdAt),
|
|
33
|
+
periodStart: dateOrUndefined(subscription?.periodStart),
|
|
34
|
+
periodEnd: dateOrUndefined(subscription?.periodEnd),
|
|
35
|
+
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? undefined,
|
|
36
|
+
trialEnd: dateOrUndefined(subscription?.trialEnd),
|
|
37
|
+
pausedAt: dateOrUndefined(subscription?.pausedAt),
|
|
38
|
+
pastDue: status === SubscriptionStatus.PastDue,
|
|
39
|
+
fallbackSku: plan.free !== true ? fallback?.sku : undefined,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What an entity may do now: its effective plan, every capability and every limit with its usage. */
|
|
44
|
+
export const entitlementViewFor = async (ctx: ApiContext, entityId: string): Promise<EntitlementView> => {
|
|
45
|
+
const at = new Date()
|
|
46
|
+
const effective = await resolveEffectivePlan(ctx, entityId, at)
|
|
47
|
+
const declared = Object.entries(effective.plan.limits ?? {})
|
|
48
|
+
const windows = new Map(declared.map(([key, declaration]) => [
|
|
49
|
+
key, windowKeyOf(declaration.kind, declaration.window, at),
|
|
50
|
+
]))
|
|
51
|
+
const usage: LimitUsage[] = []
|
|
52
|
+
if (declared.length > 0) {
|
|
53
|
+
const { items } = await usageCounters(ctx).list({
|
|
54
|
+
entityId, limitKey: [...windows.keys()], window: [...new Set(windows.values())],
|
|
55
|
+
}, { size: 0 })
|
|
56
|
+
for (const counter of items) {
|
|
57
|
+
if (windows.get(counter.limitKey) === counter.window) {
|
|
58
|
+
usage.push({ key: counter.limitKey, window: counter.window, used: counter.used })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return entitlementViewOf(effective.plan, planViewOf(effective), usage, at)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Plan resolution, the entitlement view and the usage ledger — Mongo only, never the paygate. */
|
|
67
|
+
export const makeEntitlementService = (alias: string = ENTITLEMENT_SERVICE): EntitlementService => {
|
|
68
|
+
const service: EntitlementService = createService<EntitlementService>(alias, {
|
|
69
|
+
effectivePlan: async entityId => await resolveEffectivePlan(ctxOf(), entityId),
|
|
70
|
+
entitlements: async entityId => await entitlementViewFor(ctxOf(), entityId),
|
|
71
|
+
hasCapability: async (entityId, param) => capabilityOf(await entitlementViewFor(ctxOf(), entityId), param),
|
|
72
|
+
limitState: async (entityId, key) => await limitStateOf(ctxOf(), entityId, key),
|
|
73
|
+
consume: async req => await consumeLimit(ctxOf(), req),
|
|
74
|
+
release: async req => await releaseLimit(ctxOf(), req),
|
|
75
|
+
reconcileOccupancy: async (entityId, limitKey, actual) =>
|
|
76
|
+
await reconcileOccupancyOf(ctxOf(), entityId, limitKey, actual),
|
|
77
|
+
reconcileCounters: async entityId => await reconcileLedgerCounters(ctxOf(), entityId),
|
|
78
|
+
consumption: async (entityId, limitKey, eventKey) => await consumptionOf(ctxOf(), entityId, limitKey, eventKey),
|
|
79
|
+
consumptionByRef: async (entityId, limitKey, ref) => await consumptionByRefOf(ctxOf(), entityId, limitKey, ref),
|
|
80
|
+
})
|
|
81
|
+
const ctxOf = (): ApiContext => service.assertCtx() as unknown as ApiContext
|
|
82
|
+
|
|
83
|
+
return service
|
|
84
|
+
}
|
package/src/entrypoints.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { bind } from '@owlmeans/server-entrypoint'
|
|
2
2
|
import { paymentGate } from './consts.js'
|
|
3
|
-
import { resync, webhook } from './actions/index.js'
|
|
3
|
+
import { resync, resyncSubscriptions, webhook } from './actions/index.js'
|
|
4
4
|
|
|
5
5
|
/** Runtime-local bindings for the shared immutable payment-gateway protocol tree. */
|
|
6
6
|
export const paymentGateEntrypoints = [
|
|
7
7
|
bind(paymentGate.base),
|
|
8
8
|
bind(paymentGate.webhook, webhook),
|
|
9
9
|
bind(paymentGate.resync, resync),
|
|
10
|
+
bind(paymentGate.resyncSubscriptions, resyncSubscriptions),
|
|
10
11
|
]
|
package/src/gate.ts
CHANGED
|
@@ -2,50 +2,117 @@ import { createLazyService } from '@owlmeans/context'
|
|
|
2
2
|
import type { AbstractRequest, GateService } from '@owlmeans/entrypoint'
|
|
3
3
|
import { AuthForbidden, entitySlugOf } from '@owlmeans/auth'
|
|
4
4
|
import type { Auth, PermissionSet } from '@owlmeans/auth'
|
|
5
|
-
import {
|
|
5
|
+
import { hasPermission } from '@owlmeans/iam'
|
|
6
|
+
import {
|
|
7
|
+
CapabilityRequired, ENTITLEMENT_GATE, parseEntitlementParam, promoActive,
|
|
8
|
+
} from '@owlmeans/payment'
|
|
9
|
+
import { capabilityOf } from '@owlmeans/payment'
|
|
6
10
|
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
7
|
-
import {
|
|
11
|
+
import { resolveEffectivePlan } from './plan.js'
|
|
12
|
+
import { entitlements } from './utils.js'
|
|
8
13
|
import type { Config, Context } from './types.js'
|
|
9
14
|
|
|
10
|
-
export interface
|
|
11
|
-
|
|
15
|
+
export interface EntityResolverOption {
|
|
16
|
+
/** The stable organization id a request acts for. Default: `req.entity.id`, else the token's entity. */
|
|
12
17
|
resolveEntity?: (req: AbstractRequest) => string | null
|
|
13
18
|
}
|
|
14
19
|
|
|
20
|
+
export interface CapabilityGateOptions extends EntityResolverOption {
|
|
21
|
+
/** Refuse unless the effective plan belongs to one of these products. */
|
|
22
|
+
productSkus?: string[]
|
|
23
|
+
/**
|
|
24
|
+
* Also require the token to grant the permission (IAM `hasPermission`). Off by default: a
|
|
25
|
+
* platform token carries no permissions, and the subscription is the authority.
|
|
26
|
+
*/
|
|
27
|
+
requirePermission?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @deprecated use `CapabilityGateOptions` */
|
|
31
|
+
export type EntitlementGateOptions = CapabilityGateOptions
|
|
32
|
+
|
|
33
|
+
export const defaultEntity = (req: AbstractRequest): string | null =>
|
|
34
|
+
req.entity?.id ?? entitySlugOf(req.auth as Auth | undefined) ?? null
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The capability sets an entity's effective plan grants now — sets behind a lapsed promo left out.
|
|
38
|
+
* Empty when the plan is not of one of `productSkus`.
|
|
39
|
+
*/
|
|
15
40
|
export const entitlementsOf = async (
|
|
16
41
|
ctx: ApiContext, entityId: string, productSkus?: string[],
|
|
17
42
|
): Promise<PermissionSet[]> => {
|
|
18
|
-
const {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
43
|
+
const { plan, subscription } = await resolveEffectivePlan(ctx, entityId)
|
|
44
|
+
if (productSkus != null && !productSkus.includes(plan.productSku)) {
|
|
45
|
+
return []
|
|
46
|
+
}
|
|
47
|
+
const at = new Date()
|
|
48
|
+
|
|
49
|
+
return (plan.capabilities ?? [])
|
|
50
|
+
.filter(set => promoActive(set.promo, subscription?.createdAt, at))
|
|
51
|
+
.map(({ promo: _promo, ...set }) => set)
|
|
22
52
|
}
|
|
23
53
|
|
|
24
|
-
|
|
25
|
-
|
|
54
|
+
/** An explicit `false` for the permission in the token denies, whatever the plan grants. */
|
|
55
|
+
const tokenDenies = (auth: Auth, param: string): boolean => {
|
|
56
|
+
const { scope, permission } = parseEntitlementParam(param)
|
|
57
|
+
return auth.permissions?.some(set =>
|
|
58
|
+
(scope == null || set.scope === scope) && set.permissions?.[permission] === false,
|
|
59
|
+
) ?? false
|
|
60
|
+
}
|
|
26
61
|
|
|
27
|
-
|
|
28
|
-
|
|
62
|
+
const tokenGrants = (auth: Auth, param: string): boolean => {
|
|
63
|
+
const { scope, permission } = parseEntitlementParam(param)
|
|
64
|
+
return hasPermission(auth, permission, scope != null ? { scope } : undefined)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Resolve the authenticated entity a gate asserts for, or refuse. */
|
|
68
|
+
export const gateEntityOf = (req: AbstractRequest, opts?: EntityResolverOption): { auth: Auth, entityId: string } => {
|
|
69
|
+
if (req.auth == null) {
|
|
70
|
+
throw new AuthForbidden('auth')
|
|
71
|
+
}
|
|
72
|
+
const entityId = (opts?.resolveEntity ?? defaultEntity)(req)
|
|
73
|
+
if (entityId == null || entityId === '') {
|
|
74
|
+
throw new AuthForbidden('entity')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { auth: req.auth, entityId }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The capability gate (`ENTITLEMENT_GATE`): passes when the effective plan grants ANY of the
|
|
82
|
+
* parameters (`[scope:]permission[>=n]`) and the token does not deny it. Fails closed — an unreadable
|
|
83
|
+
* store refuses. Refuses with `CapabilityRequired`, an `AuthForbidden`.
|
|
84
|
+
*/
|
|
85
|
+
export const makeCapabilityGate = (
|
|
86
|
+
alias: string = ENTITLEMENT_GATE, opts?: CapabilityGateOptions,
|
|
29
87
|
): GateService => {
|
|
30
88
|
const service = createLazyService<GateService>(alias, {
|
|
31
89
|
assert: async (req, _, params) => {
|
|
32
90
|
await service.ready()
|
|
33
91
|
const ctx = service.assertCtx<Config, Context>() as unknown as ApiContext
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
if (entityId == null || entityId === '') throw new AuthForbidden('entity')
|
|
92
|
+
const { auth, entityId } = gateEntityOf(req, opts)
|
|
93
|
+
const list = (Array.isArray(params) ? params : [params]).filter(param => typeof param === 'string')
|
|
37
94
|
|
|
38
|
-
let
|
|
95
|
+
let view
|
|
39
96
|
try {
|
|
40
|
-
|
|
97
|
+
view = await entitlements(ctx).entitlements(entityId)
|
|
41
98
|
} catch (error) {
|
|
42
|
-
console.error(`
|
|
43
|
-
throw new
|
|
99
|
+
console.error(`capability gate: cannot resolve entitlements of "${entityId}"`, error)
|
|
100
|
+
throw new CapabilityRequired(list)
|
|
44
101
|
}
|
|
45
|
-
if (!
|
|
46
|
-
throw new
|
|
102
|
+
if (opts?.productSkus != null && !opts.productSkus.includes(view.plan.productSku)) {
|
|
103
|
+
throw new CapabilityRequired(list)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const allowed = list.some(param => capabilityOf(view, param) && !tokenDenies(auth, param)
|
|
107
|
+
&& (opts?.requirePermission !== true || tokenGrants(auth, param)))
|
|
108
|
+
if (!allowed) {
|
|
109
|
+
throw new CapabilityRequired(list)
|
|
47
110
|
}
|
|
48
111
|
},
|
|
49
112
|
})
|
|
113
|
+
|
|
50
114
|
return service
|
|
51
115
|
}
|
|
116
|
+
|
|
117
|
+
/** The capability gate under its historical name. */
|
|
118
|
+
export const makeEntitlementGate = makeCapabilityGate
|
package/src/index.ts
CHANGED
|
@@ -5,9 +5,30 @@ export * from './resource.js'
|
|
|
5
5
|
export * from './observer.js'
|
|
6
6
|
export * from './service.js'
|
|
7
7
|
export * from './gate.js'
|
|
8
|
+
export * from './limit.js'
|
|
9
|
+
export * from './entitlement.js'
|
|
10
|
+
export * from './reconcile.js'
|
|
8
11
|
export * from './entrypoints.js'
|
|
9
|
-
export {
|
|
10
|
-
export {
|
|
12
|
+
export { findPlan, findProduct, freePlanOf, planRank, resolveEffectivePlan } from './plan.js'
|
|
13
|
+
export { consumeLimit, consumptionByRefOf, consumptionOf, limitStateOf, reconcileLedgerCounters, reconcileOccupancyOf, releaseLimit } from './usage.js'
|
|
11
14
|
export {
|
|
12
|
-
|
|
15
|
+
classifySubscriptionChange, commitSubscription, propagatedStateOf, subscriptionEventKey,
|
|
16
|
+
} from './subscription.js'
|
|
17
|
+
export type { CommitOptions, CommitResult } from './subscription.js'
|
|
18
|
+
export { planLookupKey, syncedPlanPrices, syncPaymentProducts, syncStripeProducts } from './sync.js'
|
|
19
|
+
export { amountCheckoutLineItem, quantityCheckoutLineItem } from './plugins/stripe.js'
|
|
20
|
+
export { assertAmountAllowed, narrowAmountFor, sessionTtlOf } from './plugins/checkout-plugins.js'
|
|
21
|
+
export * from './consumer/index.js'
|
|
22
|
+
export { makeEstimateCache, estimateStripePrice } from './plugins/estimate.js'
|
|
23
|
+
export type { EstimateCache } from './plugins/estimate.js'
|
|
24
|
+
export { applySubscription, createEventHandler, mapStatus } from './plugins/events.js'
|
|
25
|
+
export type { ApplyOptions } from './plugins/events.js'
|
|
26
|
+
export {
|
|
27
|
+
ensureWebhookEndpoint, stripeWebhookSecret, stripeWebhookSecrets, webhookUrlOf,
|
|
28
|
+
} from './plugins/webhook-manager.js'
|
|
29
|
+
export { ensurePortalConfiguration } from './plugins/portal.js'
|
|
30
|
+
export {
|
|
31
|
+
activeSubscription, apiVersionOf, billingProfiles, consumerConsents, consumerDeclarations, consumerEvents,
|
|
32
|
+
consumerRights, consumerRightsOf, entitlements, fingerprints, fulfillments, gateway, observer, paygateCustomers,
|
|
33
|
+
payment, paymentWebhooks, purchases, stripeClient, stripeConfig, subscriptions, usageCounters, usageEvents,
|
|
13
34
|
} from './utils.js'
|
package/src/limit.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createLazyService } from '@owlmeans/context'
|
|
2
|
+
import type { GateService } from '@owlmeans/entrypoint'
|
|
3
|
+
import { LIMIT_GATE, LimitExhausted, LimitUnknown, parseLimitParam } from '@owlmeans/payment'
|
|
4
|
+
import type { LimitParam, LimitView } from '@owlmeans/payment'
|
|
5
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
6
|
+
import { gateEntityOf } from './gate.js'
|
|
7
|
+
import type { EntityResolverOption } from './gate.js'
|
|
8
|
+
import { entitlements } from './utils.js'
|
|
9
|
+
import type { Config, Context } from './types.js'
|
|
10
|
+
|
|
11
|
+
export interface LimitGateOptions extends EntityResolverOption {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The limit gate (`LIMIT_GATE`): passes when ANY `limit:<key>[>=n]` parameter has at least `n` left
|
|
15
|
+
* in its current window. It never consumes — checking room and spending it are different moments,
|
|
16
|
+
* and only the handler knows the work started. Malformed parameters and keys the plan does not
|
|
17
|
+
* declare are skipped; a store error refuses. Refuses with `LimitExhausted` naming the first
|
|
18
|
+
* declared key, an `AuthForbidden`.
|
|
19
|
+
*/
|
|
20
|
+
export const makeLimitGate = (alias: string = LIMIT_GATE, opts?: LimitGateOptions): GateService => {
|
|
21
|
+
const service = createLazyService<GateService>(alias, {
|
|
22
|
+
assert: async (req, _, params) => {
|
|
23
|
+
await service.ready()
|
|
24
|
+
const ctx = service.assertCtx<Config, Context>() as unknown as ApiContext
|
|
25
|
+
const { entityId } = gateEntityOf(req, opts)
|
|
26
|
+
const list = (Array.isArray(params) ? params : [params]).filter(param => typeof param === 'string')
|
|
27
|
+
|
|
28
|
+
const parsed = list.map(parseLimitParam).filter((param): param is LimitParam => param != null)
|
|
29
|
+
if (parsed.length === 0) {
|
|
30
|
+
throw new LimitExhausted({ key: list[0] ?? 'unknown', used: 0, limit: 0 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let refused: LimitView | null = null
|
|
34
|
+
for (const param of parsed) {
|
|
35
|
+
let state: LimitView
|
|
36
|
+
try {
|
|
37
|
+
state = await entitlements(ctx).limitState(entityId, param.key)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (!(error instanceof LimitUnknown)) {
|
|
40
|
+
console.error(`limit gate: cannot read "${param.key}" of "${entityId}"`, error)
|
|
41
|
+
}
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (state.remaining >= param.atLeast) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
refused ??= state
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new LimitExhausted(refused != null
|
|
51
|
+
? { key: refused.key, used: refused.used, limit: refused.limit, resetsAt: refused.resetsAt }
|
|
52
|
+
: { key: parsed[0].key, used: 0, limit: 0 })
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return service
|
|
57
|
+
}
|