@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.
Files changed (222) hide show
  1. package/README.md +109 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +475 -52
  4. package/build/actions/index.d.ts +1 -0
  5. package/build/actions/index.d.ts.map +1 -1
  6. package/build/actions/index.js +1 -0
  7. package/build/actions/index.js.map +1 -1
  8. package/build/actions/resync-subscriptions.d.ts +3 -0
  9. package/build/actions/resync-subscriptions.d.ts.map +1 -0
  10. package/build/actions/resync-subscriptions.js +7 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +1 -0
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +7 -3
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts.map +1 -1
  17. package/build/actions/webhook.js +6 -3
  18. package/build/actions/webhook.js.map +1 -1
  19. package/build/config.d.ts +60 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +237 -5
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +75 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +94 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/consumer/capture.d.ts +74 -0
  28. package/build/consumer/capture.d.ts.map +1 -0
  29. package/build/consumer/capture.js +291 -0
  30. package/build/consumer/capture.js.map +1 -0
  31. package/build/consumer/format.d.ts +27 -0
  32. package/build/consumer/format.d.ts.map +1 -0
  33. package/build/consumer/format.js +81 -0
  34. package/build/consumer/format.js.map +1 -0
  35. package/build/consumer/handlers.d.ts +28 -0
  36. package/build/consumer/handlers.d.ts.map +1 -0
  37. package/build/consumer/handlers.js +173 -0
  38. package/build/consumer/handlers.js.map +1 -0
  39. package/build/consumer/index.d.ts +7 -0
  40. package/build/consumer/index.d.ts.map +1 -0
  41. package/build/consumer/index.js +6 -0
  42. package/build/consumer/index.js.map +1 -0
  43. package/build/consumer/mail.d.ts +27 -0
  44. package/build/consumer/mail.d.ts.map +1 -0
  45. package/build/consumer/mail.js +314 -0
  46. package/build/consumer/mail.js.map +1 -0
  47. package/build/consumer/origin.d.ts +14 -0
  48. package/build/consumer/origin.d.ts.map +1 -0
  49. package/build/consumer/origin.js +47 -0
  50. package/build/consumer/origin.js.map +1 -0
  51. package/build/consumer/reconcile.d.ts +12 -0
  52. package/build/consumer/reconcile.d.ts.map +1 -0
  53. package/build/consumer/reconcile.js +317 -0
  54. package/build/consumer/reconcile.js.map +1 -0
  55. package/build/consumer/records.d.ts +78 -0
  56. package/build/consumer/records.d.ts.map +1 -0
  57. package/build/consumer/records.js +296 -0
  58. package/build/consumer/records.js.map +1 -0
  59. package/build/consumer/service.d.ts +51 -0
  60. package/build/consumer/service.d.ts.map +1 -0
  61. package/build/consumer/service.js +760 -0
  62. package/build/consumer/service.js.map +1 -0
  63. package/build/consumer/withdrawal.d.ts +57 -0
  64. package/build/consumer/withdrawal.d.ts.map +1 -0
  65. package/build/consumer/withdrawal.js +247 -0
  66. package/build/consumer/withdrawal.js.map +1 -0
  67. package/build/entitlement.d.ts +10 -0
  68. package/build/entitlement.d.ts.map +1 -0
  69. package/build/entitlement.js +69 -0
  70. package/build/entitlement.js.map +1 -0
  71. package/build/entrypoints.d.ts +1 -1
  72. package/build/entrypoints.d.ts.map +1 -1
  73. package/build/entrypoints.js +2 -1
  74. package/build/entrypoints.js.map +1 -1
  75. package/build/gate.d.ts +32 -4
  76. package/build/gate.d.ts.map +1 -1
  77. package/build/gate.js +59 -19
  78. package/build/gate.js.map +1 -1
  79. package/build/index.d.ts +18 -3
  80. package/build/index.d.ts.map +1 -1
  81. package/build/index.js +15 -3
  82. package/build/index.js.map +1 -1
  83. package/build/limit.d.ts +13 -0
  84. package/build/limit.d.ts.map +1 -0
  85. package/build/limit.js +47 -0
  86. package/build/limit.js.map +1 -0
  87. package/build/model.d.ts +10 -1
  88. package/build/model.d.ts.map +1 -1
  89. package/build/model.js +215 -17
  90. package/build/model.js.map +1 -1
  91. package/build/observer.d.ts +9 -0
  92. package/build/observer.d.ts.map +1 -1
  93. package/build/observer.js +38 -12
  94. package/build/observer.js.map +1 -1
  95. package/build/plan.d.ts +22 -0
  96. package/build/plan.d.ts.map +1 -0
  97. package/build/plan.js +72 -0
  98. package/build/plan.js.map +1 -0
  99. package/build/plugins/checkout-plugins.d.ts +49 -0
  100. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  101. package/build/plugins/checkout-plugins.js +124 -0
  102. package/build/plugins/checkout-plugins.js.map +1 -0
  103. package/build/plugins/estimate.d.ts +43 -0
  104. package/build/plugins/estimate.d.ts.map +1 -0
  105. package/build/plugins/estimate.js +268 -0
  106. package/build/plugins/estimate.js.map +1 -0
  107. package/build/plugins/events.d.ts +45 -10
  108. package/build/plugins/events.d.ts.map +1 -1
  109. package/build/plugins/events.js +605 -136
  110. package/build/plugins/events.js.map +1 -1
  111. package/build/plugins/fx.d.ts +31 -0
  112. package/build/plugins/fx.d.ts.map +1 -0
  113. package/build/plugins/fx.js +81 -0
  114. package/build/plugins/fx.js.map +1 -0
  115. package/build/plugins/portal.d.ts +37 -0
  116. package/build/plugins/portal.d.ts.map +1 -0
  117. package/build/plugins/portal.js +265 -0
  118. package/build/plugins/portal.js.map +1 -0
  119. package/build/plugins/refunds.d.ts +33 -0
  120. package/build/plugins/refunds.d.ts.map +1 -0
  121. package/build/plugins/refunds.js +80 -0
  122. package/build/plugins/refunds.js.map +1 -0
  123. package/build/plugins/stripe.d.ts +36 -4
  124. package/build/plugins/stripe.d.ts.map +1 -1
  125. package/build/plugins/stripe.js +492 -97
  126. package/build/plugins/stripe.js.map +1 -1
  127. package/build/plugins/webhook-manager.d.ts +46 -0
  128. package/build/plugins/webhook-manager.d.ts.map +1 -0
  129. package/build/plugins/webhook-manager.js +231 -0
  130. package/build/plugins/webhook-manager.js.map +1 -0
  131. package/build/reconcile.d.ts +15 -0
  132. package/build/reconcile.d.ts.map +1 -0
  133. package/build/reconcile.js +88 -0
  134. package/build/reconcile.js.map +1 -0
  135. package/build/resource.d.ts +12 -1
  136. package/build/resource.d.ts.map +1 -1
  137. package/build/resource.js +97 -6
  138. package/build/resource.js.map +1 -1
  139. package/build/service.d.ts +28 -3
  140. package/build/service.d.ts.map +1 -1
  141. package/build/service.js +146 -19
  142. package/build/service.js.map +1 -1
  143. package/build/subscription.d.ts +48 -0
  144. package/build/subscription.d.ts.map +1 -0
  145. package/build/subscription.js +176 -0
  146. package/build/subscription.js.map +1 -0
  147. package/build/sync.d.ts +28 -1
  148. package/build/sync.d.ts.map +1 -1
  149. package/build/sync.js +208 -31
  150. package/build/sync.js.map +1 -1
  151. package/build/types.d.ts +1115 -36
  152. package/build/types.d.ts.map +1 -1
  153. package/build/usage.d.ts +63 -0
  154. package/build/usage.d.ts.map +1 -0
  155. package/build/usage.js +363 -0
  156. package/build/usage.js.map +1 -0
  157. package/build/utils.d.ts +52 -1
  158. package/build/utils.d.ts.map +1 -1
  159. package/build/utils.js +69 -7
  160. package/build/utils.js.map +1 -1
  161. package/package.json +17 -13
  162. package/src/actions/index.ts +1 -0
  163. package/src/actions/resync-subscriptions.ts +10 -0
  164. package/src/actions/resync.ts +6 -3
  165. package/src/actions/webhook.ts +5 -3
  166. package/src/config.ts +264 -8
  167. package/src/consts.ts +114 -6
  168. package/src/consumer/capture.ts +362 -0
  169. package/src/consumer/format.ts +90 -0
  170. package/src/consumer/handlers.ts +211 -0
  171. package/src/consumer/index.ts +6 -0
  172. package/src/consumer/mail.ts +368 -0
  173. package/src/consumer/origin.ts +63 -0
  174. package/src/consumer/reconcile.ts +329 -0
  175. package/src/consumer/records.ts +374 -0
  176. package/src/consumer/service.ts +868 -0
  177. package/src/consumer/withdrawal.ts +302 -0
  178. package/src/entitlement.ts +84 -0
  179. package/src/entrypoints.ts +2 -1
  180. package/src/gate.ts +88 -21
  181. package/src/index.ts +24 -3
  182. package/src/limit.ts +57 -0
  183. package/src/model.ts +237 -18
  184. package/src/observer.ts +44 -11
  185. package/src/plan.ts +89 -0
  186. package/src/plugins/checkout-plugins.ts +155 -0
  187. package/src/plugins/estimate.ts +339 -0
  188. package/src/plugins/events.ts +677 -121
  189. package/src/plugins/fx.ts +122 -0
  190. package/src/plugins/portal.ts +306 -0
  191. package/src/plugins/refunds.ts +108 -0
  192. package/src/plugins/stripe.ts +581 -96
  193. package/src/plugins/webhook-manager.ts +270 -0
  194. package/src/reconcile.ts +103 -0
  195. package/src/resource.ts +152 -7
  196. package/src/service.ts +174 -18
  197. package/src/subscription.ts +231 -0
  198. package/src/sync.ts +249 -29
  199. package/src/types.ts +1227 -32
  200. package/src/usage.ts +453 -0
  201. package/src/utils.ts +127 -10
  202. package/tests/checkout-consumer.spec.ts +348 -0
  203. package/tests/checkout-plugins.spec.ts +164 -0
  204. package/tests/checkout.spec.ts +184 -83
  205. package/tests/consumer-events.spec.ts +218 -0
  206. package/tests/consumer-fixtures.ts +132 -0
  207. package/tests/consumer-ops.spec.ts +351 -0
  208. package/tests/consumer-rights.integration.spec.ts +150 -0
  209. package/tests/consumer-rights.spec.ts +501 -0
  210. package/tests/context.ts +109 -0
  211. package/tests/entitlement.spec.ts +103 -0
  212. package/tests/estimate.spec.ts +240 -0
  213. package/tests/events.spec.ts +356 -0
  214. package/tests/fake-stripe.ts +972 -0
  215. package/tests/gate.spec.ts +62 -72
  216. package/tests/limit-gate.spec.ts +68 -0
  217. package/tests/portal.spec.ts +200 -0
  218. package/tests/protocol.spec.ts +23 -6
  219. package/tests/sync.spec.ts +114 -0
  220. package/tests/usage.integration.spec.ts +101 -0
  221. package/tests/usage.spec.ts +171 -0
  222. 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
+ }
@@ -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 { ENTITLEMENT_GATE, hasEntitlement, SubscriptionStatus } from '@owlmeans/payment'
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 { subscriptions } from './utils.js'
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 EntitlementGateOptions {
11
- productSkus?: string[]
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 { items } = await subscriptions(ctx).list({
19
- entityId, status: [SubscriptionStatus.Active, SubscriptionStatus.Trial], productSku: productSkus,
20
- }, { size: 0 })
21
- return items.flatMap(record => record.capabilities ?? [])
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
- const defaultEntity = (req: AbstractRequest): string | null =>
25
- req.entity?.id ?? entitySlugOf(req.auth as Auth | undefined) ?? null
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
- export const makeEntitlementGate = (
28
- alias: string = ENTITLEMENT_GATE, opts?: EntitlementGateOptions,
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
- if (req.auth == null) throw new AuthForbidden('auth')
35
- const entityId = (opts?.resolveEntity ?? defaultEntity)(req)
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 capabilities: PermissionSet[]
95
+ let view
39
96
  try {
40
- capabilities = await entitlementsOf(ctx, entityId, opts?.productSkus)
97
+ view = await entitlements(ctx).entitlements(entityId)
41
98
  } catch (error) {
42
- console.error(`entitlement gate: cannot read subscriptions for "${entityId}"`, error)
43
- throw new AuthForbidden('entitlement')
99
+ console.error(`capability gate: cannot resolve entitlements of "${entityId}"`, error)
100
+ throw new CapabilityRequired(list)
44
101
  }
45
- if (!params.some(param => hasEntitlement(capabilities, param))) {
46
- throw new AuthForbidden('entitlement')
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 { initialize as syncPaymentProducts, planLookupKey } from './sync.js'
10
- export { amountCheckoutLineItem } from './plugins/stripe.js'
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
- payment, gateway, observer, paygateCustomers, subscriptions, fingerprints, activeSubscription,
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
+ }