@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
package/src/model.ts CHANGED
@@ -1,37 +1,256 @@
1
1
  import type { JSONSchemaType } from 'ajv'
2
- import { PermissionSetSchema } from '@owlmeans/auth'
3
- import { LimitConfigSchema } from '@owlmeans/payment'
4
- import type { FingerprintRecord, PaygateCustomerRecord, PaymentSubscriptionRecord } from './types.js'
2
+ import { DateSchema } from '@owlmeans/auth'
3
+ import {
4
+ CancellationKindSchema, CheckoutPricingModeSchema, ConsentKindSchema, ConsumerRegionSchema,
5
+ DeclarationChannelSchema, DeclarationKindSchema, PurchaseKindSchema, SubscriptionStatusSchema,
6
+ } from '@owlmeans/payment'
7
+ import type {
8
+ BillingProfileRecord, ConsumerConsentRecord, ConsumerDeclarationRecord, ConsumerEventRecord, FingerprintRecord,
9
+ PaygateCustomerRecord, PaymentFulfillmentRecord, PaymentSubscriptionRecord, PaymentUsageCounterRecord,
10
+ PaymentUsageRecord, PaymentWebhookRecord, PurchaseRecord,
11
+ } from './types.js'
12
+
13
+ /**
14
+ * Record schemas. Every stored property is declared: the resource coerces a write through its
15
+ * schema, and a property the schema does not know is written as a string. Dates follow the record
16
+ * convention (`DateSchema`). None of these records declares an ObjectId reference — `entityId`
17
+ * is an organization key and every other id is the paygate's.
18
+ */
19
+
20
+ const str = { type: 'string' } as const
21
+ const optStr = { type: 'string', nullable: true } as const
22
+ const num = { type: 'number' } as const
23
+ const optNum = { type: 'number', nullable: true } as const
24
+ const optBool = { type: 'boolean', nullable: true } as const
25
+ const date = DateSchema
26
+ const optDate = { ...DateSchema, nullable: true }
27
+ const id = { type: 'string', nullable: true } as const
5
28
 
6
29
  export const PaygateCustomerSchema = {
7
30
  type: 'object',
8
31
  properties: {
9
- id: { type: 'string', nullable: true }, paygate: { type: 'string' }, externalId: { type: 'string' },
10
- entityId: { type: 'string', nullable: true }, profileId: { type: 'string', nullable: true },
11
- email: { type: 'string', nullable: true }, name: { type: 'string', nullable: true },
12
- taxId: { type: 'string', nullable: true },
32
+ id, paygate: str, externalId: str, entityId: optStr, profileId: optStr, email: optStr, name: optStr,
33
+ taxId: optStr, country: optStr, currency: optStr, deletedAt: optDate,
13
34
  },
14
- required: ['paygate', 'externalId'], additionalProperties: true,
35
+ required: ['paygate', 'externalId'],
36
+ additionalProperties: false,
15
37
  } as unknown as JSONSchemaType<PaygateCustomerRecord>
16
38
 
17
39
  export const PaymentSubscriptionSchema = {
18
40
  type: 'object',
19
41
  properties: {
20
- id: { type: 'string', nullable: true }, sku: { type: 'string' }, productSku: { type: 'string' },
21
- entityId: { type: 'string' }, service: { type: 'string' }, paygate: { type: 'string' },
22
- externalId: { type: 'string' }, status: { type: 'string' }, kind: { type: 'string' },
23
- capabilities: { type: 'array', items: PermissionSetSchema as never, nullable: true },
24
- limits: { type: 'object', nullable: true, additionalProperties: LimitConfigSchema as never, required: [] },
42
+ id, entityId: str, planSku: str, productSku: str, service: str, paygate: str, externalId: str,
43
+ itemId: optStr, priceId: optStr, status: SubscriptionStatusSchema, externalStatus: optStr, rank: num,
44
+ periodStart: optDate, periodEnd: optDate, cancelAtPeriodEnd: optBool, canceledAt: optDate,
45
+ endedAt: optDate, pausedAt: optDate, trialEnd: optDate, latestInvoiceId: optStr, customerId: optStr,
46
+ disputedAt: optDate, disputeStatus: optStr, createdAt: date, updatedAt: optDate, syncedAt: optDate,
47
+ lastEventId: optStr, initialPropagatedAt: optDate, currency: optStr,
48
+ checkoutSessionId: optStr, purchaseId: optStr, firstInvoiceId: optStr, country: optStr, email: optStr,
49
+ amountTotalMinor: optNum, amountTaxMinor: optNum, termsAccepted: optBool, startRequestId: optStr,
50
+ withdrawnAt: optDate,
51
+ propagated: {
52
+ type: 'object',
53
+ nullable: true,
54
+ properties: {
55
+ planSku: str, rank: num, status: SubscriptionStatusSchema, cancelAtPeriodEnd: optBool,
56
+ pausedAt: optDate, renewedInvoiceId: optStr,
57
+ },
58
+ required: ['planSku', 'rank', 'status'],
59
+ additionalProperties: false,
60
+ },
25
61
  },
26
- required: ['sku', 'productSku', 'entityId', 'service', 'paygate', 'externalId', 'status', 'kind'],
27
- additionalProperties: true,
62
+ required: [
63
+ 'entityId', 'planSku', 'productSku', 'service', 'paygate', 'externalId', 'status', 'rank', 'createdAt',
64
+ ],
65
+ additionalProperties: false,
28
66
  } as unknown as JSONSchemaType<PaymentSubscriptionRecord>
29
67
 
68
+ export const PaymentFulfillmentSchema = {
69
+ type: 'object',
70
+ properties: {
71
+ id, entityId: str, productSku: str, planSku: optStr, service: str, paygate: str, externalId: str,
72
+ paymentIntentId: optStr, chargeId: optStr, invoiceId: optStr, mode: CheckoutPricingModeSchema,
73
+ units: optNum, amountMinor: optNum, sourceChargeAmountMinor: optNum, amountCurrency: optStr,
74
+ chargeAmountMinor: optNum, currency: optStr, createdAt: date,
75
+ fulfilledAt: optDate, failedAt: optDate, refundedMinor: optNum, refundedAt: optDate,
76
+ disputedAt: optDate, disputeStatus: optStr,
77
+ country: optStr, email: optStr, profileId: optStr, amountTotalMinor: optNum, amountTaxMinor: optNum,
78
+ termsAccepted: optBool, purchaseId: optStr,
79
+ },
80
+ required: ['entityId', 'productSku', 'service', 'paygate', 'externalId', 'mode', 'createdAt'],
81
+ additionalProperties: false,
82
+ } as unknown as JSONSchemaType<PaymentFulfillmentRecord>
83
+
84
+ export const PaymentWebhookSchema = {
85
+ type: 'object',
86
+ properties: {
87
+ id, paygate: str, service: str, url: str, externalId: str,
88
+ /** The signing secret: field-encrypted when the database is configured with a key. */
89
+ secret: { type: 'string', secure: true },
90
+ apiVersion: str, events: { type: 'array', items: str }, hash: str, createdAt: date, updatedAt: optDate,
91
+ },
92
+ required: ['paygate', 'service', 'url', 'externalId', 'secret', 'apiVersion', 'events', 'hash', 'createdAt'],
93
+ additionalProperties: false,
94
+ } as unknown as JSONSchemaType<PaymentWebhookRecord>
95
+
96
+ export const PaymentUsageSchema = {
97
+ type: 'object',
98
+ properties: {
99
+ id, entityId: str, limitKey: str, window: str, delta: num, eventKey: str, ref: optStr, reason: optStr,
100
+ planSku: optStr, createdAt: date, releasedAt: optDate,
101
+ },
102
+ required: ['entityId', 'limitKey', 'window', 'delta', 'eventKey', 'createdAt'],
103
+ additionalProperties: false,
104
+ } as unknown as JSONSchemaType<PaymentUsageRecord>
105
+
106
+ export const PaymentUsageCounterSchema = {
107
+ type: 'object',
108
+ properties: {
109
+ id, entityId: str, limitKey: str, window: str, used: num, limit: num, planSku: optStr,
110
+ overSince: optDate, updatedAt: date, reconciledAt: optDate,
111
+ },
112
+ required: ['entityId', 'limitKey', 'window', 'used', 'limit', 'updatedAt'],
113
+ additionalProperties: false,
114
+ } as unknown as JSONSchemaType<PaymentUsageCounterRecord>
115
+
30
116
  export const FingerprintSchema = {
31
117
  type: 'object',
32
118
  properties: {
33
- id: { type: 'string', nullable: true }, sku: { type: 'string' }, hash: { type: 'string' },
34
- productId: { type: 'string', nullable: true },
119
+ id, sku: str, hash: str, productId: optStr, externalId: optStr,
120
+ prices: {
121
+ type: 'array',
122
+ nullable: true,
123
+ items: {
124
+ type: 'object',
125
+ properties: {
126
+ planSku: str, priceId: str, lookupKey: str, currency: str, unitAmount: num,
127
+ // An array, never a currency-keyed map: the resource coerces a map's values to strings.
128
+ options: {
129
+ type: 'array',
130
+ items: {
131
+ type: 'object', properties: { currency: str, unitAmount: num },
132
+ required: ['currency', 'unitAmount'], additionalProperties: false,
133
+ },
134
+ },
135
+ taxBehavior: optStr, interval: optStr, sourceUnitAmount: num, sourceCurrency: str, syncedAt: date,
136
+ },
137
+ required: [
138
+ 'planSku', 'priceId', 'lookupKey', 'currency', 'unitAmount', 'options', 'sourceUnitAmount',
139
+ 'sourceCurrency', 'syncedAt',
140
+ ],
141
+ additionalProperties: false,
142
+ },
143
+ },
144
+ updatedAt: date,
35
145
  },
36
- required: ['sku', 'hash'], additionalProperties: true,
146
+ required: ['sku', 'hash', 'updatedAt'],
147
+ additionalProperties: false,
37
148
  } as unknown as JSONSchemaType<FingerprintRecord>
149
+
150
+ /** An optional enum: `null` must be listed for a validator to accept it. */
151
+ const nullableEnum = (values: readonly unknown[]) => ({ type: 'string', enum: [...values, null], nullable: true })
152
+
153
+ /** The request evidence every consumer act carries (`RequestOrigin`). */
154
+ const origin = {
155
+ ip: optStr, forwardedFor: optStr, userAgent: optStr, ipCountry: optStr, acceptLanguage: optStr, via: optStr,
156
+ } as const
157
+
158
+ const links = {
159
+ type: 'object',
160
+ properties: {
161
+ billingTerms: str, withdrawalInformation: optStr, withdrawalForm: optStr, withdrawalFunction: optStr,
162
+ cancellation: optStr,
163
+ },
164
+ required: ['billingTerms'],
165
+ additionalProperties: false,
166
+ } as const
167
+
168
+ export const BillingProfileSchema = {
169
+ type: 'object',
170
+ properties: {
171
+ id, entityId: str, country: str, region: ConsumerRegionSchema, currency: str, language: str,
172
+ source: { type: 'string', enum: ['checkout', 'customer', 'manual'] }, paygate: str, customerId: optStr,
173
+ sessionId: optStr, ipCountry: optStr, email: optStr, name: optStr, business: optBool, lockedAt: date,
174
+ createdAt: date, updatedAt: optDate,
175
+ },
176
+ required: ['entityId', 'country', 'region', 'currency', 'language', 'source', 'paygate', 'lockedAt', 'createdAt'],
177
+ additionalProperties: false,
178
+ } as unknown as JSONSchemaType<BillingProfileRecord>
179
+
180
+ export const PurchaseSchema = {
181
+ type: 'object',
182
+ properties: {
183
+ id, purchaseId: str, contractRef: str, entityId: str, kind: PurchaseKindSchema, paygate: str,
184
+ sessionId: optStr, subscriptionId: optStr, paymentIntentId: optStr, invoiceId: optStr, invoiceNumber: optStr,
185
+ invoiceLineId: optStr, productSku: str, planSku: optStr, profileId: optStr, country: optStr,
186
+ region: nullableEnum(ConsumerRegionSchema.enum), ipCountry: optStr, inScope: { type: 'boolean' },
187
+ language: str, email: optStr, name: optStr, business: optBool, currency: str, amountSubtotalMinor: num,
188
+ amountTaxMinor: num, amountTotalMinor: num, presentmentCurrency: optStr, presentmentAmountMinor: optNum,
189
+ netAmountMinor: optNum, amountCurrency: optStr, units: optNum, taxBehavior: optStr, termsAccepted: optBool,
190
+ textVersion: optStr, copyVersion: optStr, startRequestId: optStr, servicesStartedAt: optDate,
191
+ confirmationMailAt: optDate, purchasedAt: date, deadline: optDate, consentId: optStr, consentedAt: optDate,
192
+ withdrawalId: optStr, withdrawnAt: optDate, refundedMinor: optNum, refundedAt: optDate, cancellationId: optStr,
193
+ cancelEffectiveAt: optDate, createdAt: date, updatedAt: optDate,
194
+ },
195
+ required: [
196
+ 'purchaseId', 'contractRef', 'entityId', 'kind', 'paygate', 'productSku', 'inScope', 'language', 'currency',
197
+ 'amountSubtotalMinor', 'amountTaxMinor', 'amountTotalMinor', 'purchasedAt', 'createdAt',
198
+ ],
199
+ additionalProperties: false,
200
+ } as unknown as JSONSchemaType<PurchaseRecord>
201
+
202
+ export const ConsumerConsentSchema = {
203
+ type: 'object',
204
+ properties: {
205
+ id, kind: ConsentKindSchema, entityId: str, profileId: optStr, name: optStr, email: optStr,
206
+ purchaseIds: { type: 'array', items: str },
207
+ planSku: optStr, planName: optStr, textVersion: str, copyVersion: str, language: str, uiLanguage: optStr, trader: str,
208
+ text: {
209
+ type: 'object',
210
+ properties: { request: str, acknowledgement: str, checkbox: str },
211
+ required: ['request', 'acknowledgement', 'checkbox'],
212
+ additionalProperties: false,
213
+ },
214
+ links, deadline: optDate, decidedAt: date, expiresAt: optDate, ...origin,
215
+ },
216
+ required: [
217
+ 'kind', 'entityId', 'purchaseIds', 'textVersion', 'copyVersion', 'language', 'trader', 'text', 'links',
218
+ 'decidedAt',
219
+ ],
220
+ additionalProperties: false,
221
+ } as unknown as JSONSchemaType<ConsumerConsentRecord>
222
+
223
+ export const ConsumerDeclarationSchema = {
224
+ type: 'object',
225
+ properties: {
226
+ id, kind: DeclarationKindSchema, channel: DeclarationChannelSchema, entityId: optStr, purchaseId: optStr,
227
+ subscriptionId: optStr, contractRef: optStr, name: str, email: str,
228
+ cancellationKind: nullableEnum(CancellationKindSchema.enum), reason: optStr,
229
+ effective: nullableEnum(['earliest', 'date']), requestedDate: optStr,
230
+ language: str, textVersion: optStr, copyVersion: str, receivedAt: date, matched: { type: 'boolean' },
231
+ profileId: optStr, duplicateOf: optStr, status: str, refundMinor: optNum, currency: optStr,
232
+ effectiveAt: optDate, ...origin,
233
+ },
234
+ required: ['kind', 'channel', 'name', 'email', 'language', 'copyVersion', 'receivedAt', 'matched', 'status'],
235
+ additionalProperties: false,
236
+ } as unknown as JSONSchemaType<ConsumerDeclarationRecord>
237
+
238
+ export const ConsumerEventSchema = {
239
+ type: 'object',
240
+ properties: {
241
+ id, recordId: str,
242
+ recordKind: { type: 'string', enum: ['purchase', 'consent', 'declaration', 'profile', 'checkout'] },
243
+ entityId: optStr,
244
+ action: {
245
+ type: 'string',
246
+ enum: [
247
+ 'mail', 'computed', 'meter', 'refund', 'credit-note', 'subscription-cancel', 'cancel-scheduled', 'observers',
248
+ 'lock', 'lock-mismatch', 'relock', 'unlock', 'duplicate', 'checkout-terms-fallback',
249
+ ],
250
+ },
251
+ step: optStr, ok: { type: 'boolean' }, skipped: optBool, externalId: optStr, amountMinor: optNum,
252
+ currency: optStr, detail: optStr, error: optStr, at: date,
253
+ },
254
+ required: ['recordId', 'recordKind', 'action', 'ok', 'at'],
255
+ additionalProperties: false,
256
+ } as unknown as JSONSchemaType<ConsumerEventRecord>
package/src/observer.ts CHANGED
@@ -1,21 +1,54 @@
1
1
  import { createLazyService } from '@owlmeans/context'
2
2
  import { PAYMENT_OBSERVER } from './consts.js'
3
- import type { CompletionObserver, Config, Context, SubscriptionCallback, TopUpCallback } from './types.js'
3
+ import type {
4
+ CancellationCallback, CompletionObserver, Config, ConsentCallback, Context, DisputeCallback,
5
+ PaymentFailedCallback, RefundCallback, SubscriptionCallback, TopUpCallback, WithdrawalCallback,
6
+ } from './types.js'
4
7
 
8
+ /**
9
+ * The application's side of payment completion. Callbacks run sequentially and are awaited; a
10
+ * throw escapes to the webhook so the paygate delivers the event again — every callback must be
11
+ * idempotent by the `eventKey` (`externalId` for a top-up) it is handed.
12
+ *
13
+ * The consumer-rights callbacks (`onConsent`, `onWithdrawal`, `onCancellation`) run after the
14
+ * records and the paygate steps of their act; a throw is recorded as an `observers` event and
15
+ * retried by the consumer-rights `reconcile()` — they, too, are idempotent by `eventKey`.
16
+ */
5
17
  export const makeCompletionObserverService = (
6
18
  alias: string = PAYMENT_OBSERVER,
7
19
  ): CompletionObserver => {
8
- const topUpCallbacks: TopUpCallback[] = []
9
- const subscriptionCallbacks: SubscriptionCallback[] = []
20
+ const topUp: TopUpCallback[] = []
21
+ const subscription: SubscriptionCallback[] = []
22
+ const refund: RefundCallback[] = []
23
+ const dispute: DisputeCallback[] = []
24
+ const paymentFailed: PaymentFailedCallback[] = []
25
+ const consent: ConsentCallback[] = []
26
+ const withdrawal: WithdrawalCallback[] = []
27
+ const cancellation: CancellationCallback[] = []
28
+
29
+ const run = async <E>(callbacks: Array<(event: E, ctx: never) => Promise<void>>, event: E, ctx: unknown) => {
30
+ for (const callback of callbacks) {
31
+ await callback(event, ctx as never)
32
+ }
33
+ }
34
+
10
35
  return createLazyService<CompletionObserver>(alias, {
11
- onTopUp: cb => { topUpCallbacks.push(cb) },
12
- onSubscription: cb => { subscriptionCallbacks.push(cb) },
13
- propagateTopUp: async (completion, ctx) => {
14
- for (const callback of topUpCallbacks) await callback(completion, ctx)
15
- },
16
- propagateSubscription: async (completion, ctx) => {
17
- for (const callback of subscriptionCallbacks) await callback(completion, ctx)
18
- },
36
+ onTopUp: cb => { topUp.push(cb) },
37
+ onSubscription: cb => { subscription.push(cb) },
38
+ onRefund: cb => { refund.push(cb) },
39
+ onDispute: cb => { dispute.push(cb) },
40
+ onPaymentFailed: cb => { paymentFailed.push(cb) },
41
+ onConsent: cb => { consent.push(cb) },
42
+ onWithdrawal: cb => { withdrawal.push(cb) },
43
+ onCancellation: cb => { cancellation.push(cb) },
44
+ propagateTopUp: async (completion, ctx) => { await run(topUp, completion, ctx) },
45
+ propagateSubscription: async (event, ctx) => { await run(subscription, event, ctx) },
46
+ propagateRefund: async (event, ctx) => { await run(refund, event, ctx) },
47
+ propagateDispute: async (event, ctx) => { await run(dispute, event, ctx) },
48
+ propagatePaymentFailed: async (event, ctx) => { await run(paymentFailed, event, ctx) },
49
+ propagateConsent: async (event, ctx) => { await run(consent, event, ctx) },
50
+ propagateWithdrawal: async (event, ctx) => { await run(withdrawal, event, ctx) },
51
+ propagateCancellation: async (event, ctx) => { await run(cancellation, event, ctx) },
19
52
  }, service => async () => { service.initialized = true })
20
53
  }
21
54
 
package/src/plan.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { ENTITLING_STATUSES, INTERNAL_PAYGATE, PlanRequired } from '@owlmeans/payment'
2
+ import type { ProductPlan } from '@owlmeans/payment'
3
+ import type { Context as ApiContext } from '@owlmeans/server-api'
4
+ import { payment, subscriptions } from './utils.js'
5
+ import type { EffectivePlan, PaymentPlan, PaymentProduct, PaymentSubscriptionRecord } from './types.js'
6
+
7
+ /** A plan's rank; an absent rank reads as `0`. */
8
+ export const planRank = (plan: Pick<ProductPlan, 'rank'>): number => plan.rank ?? 0
9
+
10
+ /** A plan by sku, or `null` when the catalogue no longer declares it. */
11
+ export const findPlan = async (ctx: ApiContext, planSku: string): Promise<PaymentPlan | null> => {
12
+ try {
13
+ return await payment(ctx).plan(planSku) as PaymentPlan
14
+ } catch {
15
+ return null
16
+ }
17
+ }
18
+
19
+ /** A product by sku, or `null`. */
20
+ export const findProduct = async (ctx: ApiContext, productSku: string): Promise<PaymentProduct | null> => {
21
+ try {
22
+ return await payment(ctx).product(productSku) as PaymentProduct
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ /** Every plan of every declared product. */
29
+ export const catalogPlans = async (ctx: ApiContext): Promise<PaymentPlan[]> => {
30
+ const products = await payment(ctx).products()
31
+ const plans: PaymentPlan[] = []
32
+ for (const product of products) {
33
+ plans.push(...await payment(ctx).allPlans(product.sku) as PaymentPlan[])
34
+ }
35
+
36
+ return plans
37
+ }
38
+
39
+ /** The declared free plan an entity falls back to: the lowest rank, then the sku. */
40
+ export const freePlanOf = async (ctx: ApiContext): Promise<PaymentPlan | null> =>
41
+ (await catalogPlans(ctx))
42
+ .filter(plan => plan.free === true)
43
+ .sort((a, b) => planRank(a) - planRank(b) || a.sku.localeCompare(b.sku))[0] ?? null
44
+
45
+ /** An internal grant with a period end stops entitling at that instant; a paygate row never does. */
46
+ const stillEntitles = (row: PaymentSubscriptionRecord, at: Date): boolean =>
47
+ row.paygate !== INTERNAL_PAYGATE || row.periodEnd == null || new Date(row.periodEnd).getTime() > at.getTime()
48
+
49
+ const unknownPlans = new Set<string>()
50
+
51
+ /**
52
+ * The plan an entity holds: its highest-ranked subscription in an entitling status (the catalogue
53
+ * rank, the stored one for a plan the catalogue no longer declares; the newest first on a tie),
54
+ * else the declared free plan.
55
+ *
56
+ * @throws PlanRequired when there is neither.
57
+ */
58
+ export const resolveEffectivePlan = async (
59
+ ctx: ApiContext, entityId: string, at: Date = new Date(),
60
+ ): Promise<EffectivePlan> => {
61
+ const fallback = await freePlanOf(ctx)
62
+ const { items } = await subscriptions(ctx).list(
63
+ { entityId, status: [...ENTITLING_STATUSES] }, { size: 0 },
64
+ )
65
+ const candidates: Array<{ plan: PaymentPlan, row: PaymentSubscriptionRecord, rank: number }> = []
66
+ for (const row of items.filter(item => stillEntitles(item, at))) {
67
+ const plan = await findPlan(ctx, row.planSku)
68
+ if (plan == null) {
69
+ if (!unknownPlans.has(row.planSku)) {
70
+ unknownPlans.add(row.planSku)
71
+ console.warn(`[payment] subscription "${row.externalId}" names unknown plan "${row.planSku}"; ignored`)
72
+ }
73
+ continue
74
+ }
75
+ candidates.push({ plan, row, rank: planRank(plan) })
76
+ }
77
+ candidates.sort((a, b) => b.rank - a.rank
78
+ || new Date(b.row.createdAt).getTime() - new Date(a.row.createdAt).getTime())
79
+
80
+ const best = candidates[0]
81
+ if (best != null) {
82
+ return { plan: best.plan, subscription: best.row, fallback }
83
+ }
84
+ if (fallback != null) {
85
+ return { plan: fallback, subscription: null, fallback }
86
+ }
87
+
88
+ throw new PlanRequired(entityId)
89
+ }
@@ -0,0 +1,155 @@
1
+ import { CheckoutLimitExceeded, narrowAmountPolicy } from '@owlmeans/payment'
2
+ import type { AmountNarrowing, AmountPolicyView } from '@owlmeans/payment'
3
+ import type { Context as ApiContext } from '@owlmeans/server-api'
4
+ import { STRIPE_SESSION_TTL_MAX_SECONDS, STRIPE_SESSION_TTL_MIN_SECONDS } from '../consts.js'
5
+ import type {
6
+ CheckoutAttempt, CheckoutNarrowInput, CheckoutPlugin, CheckoutSettled,
7
+ } from '../types.js'
8
+
9
+ export interface CheckoutPluginRegistry {
10
+ use: (plugin: CheckoutPlugin) => void
11
+ list: () => readonly CheckoutPlugin[]
12
+ }
13
+
14
+ /**
15
+ * The gateway's checkout plugins, the way `ExecutionService.use` seats its own: a plugin with an
16
+ * alias already registered replaces it in place (a layer wired twice must not answer twice), one
17
+ * without an alias is appended.
18
+ */
19
+ export const makeCheckoutPluginRegistry = (): CheckoutPluginRegistry => {
20
+ const plugins: CheckoutPlugin[] = []
21
+
22
+ return {
23
+ use: plugin => {
24
+ const at = plugin.alias != null ? plugins.findIndex(entry => entry.alias === plugin.alias) : -1
25
+ if (at < 0) {
26
+ plugins.push(plugin)
27
+ } else {
28
+ plugins[at] = plugin
29
+ }
30
+ },
31
+ list: () => [...plugins],
32
+ }
33
+ }
34
+
35
+ /**
36
+ * An entity's amount policy as every plugin narrows it now — the ONE computation both
37
+ * `gateway.amountPolicy` and checkout enforcement use, so a control and a refusal cannot disagree.
38
+ * A plugin's error propagates: a narrowing that cannot be computed fails closed.
39
+ */
40
+ export const narrowAmountFor = async (
41
+ ctx: ApiContext, plugins: readonly CheckoutPlugin[], input: CheckoutNarrowInput,
42
+ ): Promise<AmountPolicyView> => {
43
+ const narrowings: AmountNarrowing[] = []
44
+ for (const plugin of plugins) {
45
+ if (plugin.narrow == null) {
46
+ continue
47
+ }
48
+ const narrowing = await plugin.narrow(ctx, input)
49
+ if (narrowing != null) {
50
+ narrowings.push(narrowing)
51
+ }
52
+ }
53
+
54
+ return narrowAmountPolicy(input.base, narrowings, {
55
+ productSku: input.productSku, ...(input.planSku != null ? { planSku: input.planSku } : {}),
56
+ })
57
+ }
58
+
59
+ /**
60
+ * Refuse an amount the narrowed view does not allow: any amount while `blocked`, else one above
61
+ * the narrowed maximum.
62
+ *
63
+ * @throws CheckoutLimitExceeded (409)
64
+ */
65
+ export const assertAmountAllowed = (view: AmountPolicyView, amountMinor: number): void => {
66
+ const limit = view.limit
67
+ if (limit == null || (!limit.blocked && amountMinor <= limit.maximumMinor)) {
68
+ return
69
+ }
70
+ throw new CheckoutLimitExceeded({
71
+ reason: limit.reason ?? 'limit', maximumMinor: limit.maximumMinor, currency: limit.currency,
72
+ ...(limit.resetsAt != null ? { resetsAt: limit.resetsAt } : {}),
73
+ })
74
+ }
75
+
76
+ /**
77
+ * The session lifetime: the smallest `sessionTtlSeconds` any plugin declares, clamped to Stripe's
78
+ * 30 minutes – 24 hours; `undefined` when none declares one (Stripe's own default).
79
+ */
80
+ export const sessionTtlOf = (plugins: readonly CheckoutPlugin[]): number | undefined => {
81
+ const declared = plugins
82
+ .map(plugin => plugin.sessionTtlSeconds)
83
+ .filter((ttl): ttl is number => typeof ttl === 'number' && Number.isFinite(ttl))
84
+ if (declared.length === 0) {
85
+ return undefined
86
+ }
87
+
88
+ return Math.min(STRIPE_SESSION_TTL_MAX_SECONDS, Math.max(STRIPE_SESSION_TTL_MIN_SECONDS, Math.floor(Math.min(...declared))))
89
+ }
90
+
91
+ /** A plugin that admitted an attempt, with the reservation it holds. */
92
+ export interface Admitted {
93
+ plugin: CheckoutPlugin
94
+ reservationId?: string
95
+ }
96
+
97
+ /**
98
+ * Tell plugins how a checkout ended. A plugin's error is logged, never raised: a hold carries its
99
+ * own TTL, and the webhook that reports the outcome must not be redelivered for it.
100
+ */
101
+ export const settleCheckout = async (
102
+ ctx: ApiContext, plugins: readonly CheckoutPlugin[], settled: CheckoutSettled,
103
+ ): Promise<void> => {
104
+ for (const plugin of plugins) {
105
+ if (plugin.settled == null) {
106
+ continue
107
+ }
108
+ try {
109
+ await plugin.settled(ctx, settled)
110
+ } catch (error) {
111
+ console.error(`[payment] checkout plugin "${plugin.alias ?? 'anonymous'}" failed to settle`, error)
112
+ }
113
+ }
114
+ }
115
+
116
+ /** Release what admitted plugins hold for a checkout that never became usable. */
117
+ export const releaseAdmitted = async (
118
+ ctx: ApiContext, admitted: readonly Admitted[], attempt: CheckoutAttempt, sessionId?: string,
119
+ ): Promise<void> => {
120
+ for (const { plugin, reservationId } of admitted) {
121
+ await settleCheckout(ctx, [plugin], {
122
+ entityId: attempt.entityId, productSku: attempt.productSku,
123
+ ...(attempt.planSku != null ? { planSku: attempt.planSku } : {}),
124
+ ...(sessionId != null ? { sessionId } : {}),
125
+ ...(reservationId != null ? { reservationId } : {}),
126
+ ...(attempt.amountMinor != null ? { amountMinor: attempt.amountMinor } : {}),
127
+ outcome: 'failed', at: new Date(),
128
+ })
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Ask every plugin to admit the attempt, in order. A veto releases what the earlier plugins
134
+ * admitted and propagates.
135
+ */
136
+ export const admitCheckout = async (
137
+ ctx: ApiContext, plugins: readonly CheckoutPlugin[], attempt: CheckoutAttempt,
138
+ ): Promise<Admitted[]> => {
139
+ const admitted: Admitted[] = []
140
+ for (const plugin of plugins) {
141
+ if (plugin.admit == null) {
142
+ admitted.push({ plugin })
143
+ continue
144
+ }
145
+ try {
146
+ const admission = await plugin.admit(ctx, attempt)
147
+ admitted.push({ plugin, ...(admission?.reservationId != null ? { reservationId: admission.reservationId } : {}) })
148
+ } catch (error) {
149
+ await releaseAdmitted(ctx, admitted.filter(entry => entry.plugin.admit != null), attempt)
150
+ throw error
151
+ }
152
+ }
153
+
154
+ return admitted
155
+ }