@owlmeans/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 (72) hide show
  1. package/README.md +18 -26
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/payment/SKILL.md +137 -26
  4. package/build/advertise.d.ts +2 -0
  5. package/build/advertise.d.ts.map +1 -0
  6. package/build/advertise.js +11 -0
  7. package/build/advertise.js.map +1 -0
  8. package/build/consts.d.ts +5 -23
  9. package/build/consts.d.ts.map +1 -1
  10. package/build/consts.js +9 -23
  11. package/build/consts.js.map +1 -1
  12. package/build/entitlement.d.ts +61 -0
  13. package/build/entitlement.d.ts.map +1 -0
  14. package/build/entitlement.js +98 -0
  15. package/build/entitlement.js.map +1 -0
  16. package/build/entrypoints.d.ts +25 -0
  17. package/build/entrypoints.d.ts.map +1 -0
  18. package/build/entrypoints.js +43 -0
  19. package/build/entrypoints.js.map +1 -0
  20. package/build/index.d.ts +4 -1
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +4 -1
  23. package/build/index.js.map +1 -1
  24. package/build/model/checkout.d.ts +2 -3
  25. package/build/model/checkout.d.ts.map +1 -1
  26. package/build/model/checkout.js +8 -6
  27. package/build/model/checkout.js.map +1 -1
  28. package/build/model/index.d.ts +1 -0
  29. package/build/model/index.d.ts.map +1 -1
  30. package/build/model/index.js +1 -0
  31. package/build/model/index.js.map +1 -1
  32. package/build/model/plan.d.ts.map +1 -1
  33. package/build/model/plan.js +5 -1
  34. package/build/model/plan.js.map +1 -1
  35. package/build/model/pricing.d.ts +4 -0
  36. package/build/model/pricing.d.ts.map +1 -0
  37. package/build/model/pricing.js +31 -0
  38. package/build/model/pricing.js.map +1 -0
  39. package/build/model/subscription.d.ts +2 -2
  40. package/build/model/subscription.d.ts.map +1 -1
  41. package/build/model/subscription.js +9 -14
  42. package/build/model/subscription.js.map +1 -1
  43. package/build/pricing.d.ts +7 -0
  44. package/build/pricing.d.ts.map +1 -0
  45. package/build/pricing.js +63 -0
  46. package/build/pricing.js.map +1 -0
  47. package/build/types.d.ts +25 -3
  48. package/build/types.d.ts.map +1 -1
  49. package/package.json +13 -11
  50. package/src/advertise.ts +15 -0
  51. package/src/consts.ts +10 -24
  52. package/src/entitlement.ts +125 -0
  53. package/src/entrypoints.ts +57 -0
  54. package/src/index.ts +4 -1
  55. package/src/model/checkout.ts +8 -6
  56. package/src/model/index.ts +1 -0
  57. package/src/model/plan.ts +5 -1
  58. package/src/model/pricing.ts +34 -0
  59. package/src/model/subscription.ts +11 -14
  60. package/src/pricing.ts +87 -0
  61. package/src/types.ts +29 -3
  62. package/tests/entitlement.spec.ts +90 -0
  63. package/tests/pricing.spec.ts +33 -0
  64. package/tests/protocol.spec.ts +51 -0
  65. package/tests/tsconfig.json +11 -0
  66. package/tsconfig.json +6 -1
  67. package/build/.gitkeep +0 -0
  68. package/build/modules.d.ts +0 -3
  69. package/build/modules.d.ts.map +0 -1
  70. package/build/modules.js +0 -18
  71. package/build/modules.js.map +0 -1
  72. package/src/modules.ts +0 -43
@@ -0,0 +1,125 @@
1
+ import type { EntrypointOptions } from '@owlmeans/entrypoint'
2
+ import type { PermissionSet } from '@owlmeans/auth'
3
+
4
+ /**
5
+ * The gate alias a paid capability is asserted under.
6
+ *
7
+ * Distinct from `paymentGate.base` (a ROUTE id) and from the gateway service alias — three
8
+ * different things that would otherwise all be called "payment gate".
9
+ */
10
+ export const ENTITLEMENT_GATE = 'entitlement-gate'
11
+
12
+ /**
13
+ * The capability scope that carries FEATURE flags.
14
+ *
15
+ * Kept apart from `renewable`, which carries numeric quotas that are consumed or counted. Merging
16
+ * them would make "has one production slot left" and "may remove the platform credit" the same
17
+ * number, and the first purchase that spent the quota would take the feature with it.
18
+ */
19
+ export const CAPABILITY_FEATURE_SCOPE = 'feature'
20
+
21
+ export interface EntitlementParam {
22
+ /** Capability scope. Absent means any scope. */
23
+ scope?: string
24
+ /** The permission key inside `PermissionSet.permissions`. */
25
+ permission: string
26
+ /** Numeric floor: `credits>=100` passes when the held value is a number at least that big. */
27
+ atLeast?: number
28
+ }
29
+
30
+ /**
31
+ * Grammar: `[<scope>:]<permission>[>=<n>]`.
32
+ *
33
+ * `feature:branding--whitelabel` · `renewable:credits>=100` · `production--standalone`
34
+ *
35
+ * `@` is deliberately not part of it: that is `@owlmeans/iam`'s resource-selector syntax, and a
36
+ * grammar that reused it would make two different things look the same in a route declaration.
37
+ */
38
+ export const parseEntitlementParam = (param: string): EntitlementParam => {
39
+ const [scoped, floor] = param.split('>=')
40
+ const colon = scoped.indexOf(':')
41
+ const parsed: EntitlementParam = colon >= 0
42
+ ? { scope: scoped.slice(0, colon), permission: scoped.slice(colon + 1) }
43
+ : { permission: scoped }
44
+
45
+ if (floor != null) {
46
+ const value = Number.parseFloat(floor)
47
+ if (!Number.isNaN(value)) {
48
+ parsed.atLeast = value
49
+ }
50
+ }
51
+
52
+ return parsed
53
+ }
54
+
55
+ export const formatEntitlementParam = (parsed: EntitlementParam): string =>
56
+ `${parsed.scope != null ? `${parsed.scope}:` : ''}${parsed.permission}` +
57
+ `${parsed.atLeast != null ? `>=${parsed.atLeast}` : ''}`
58
+
59
+ /**
60
+ * Whether a union of capability sets satisfies one requirement.
61
+ *
62
+ * Pure, and shared by the server gate and the browser: the UI has to know whether to render a
63
+ * control disabled, and a second implementation of the rule for the client would eventually
64
+ * disagree with the one that actually refuses.
65
+ *
66
+ * A malformed parameter answers `false` rather than throwing. A gate that crashed on a typo would
67
+ * take down the endpoint it guards, which is a strictly worse failure than refusing it.
68
+ */
69
+ export const hasEntitlement = (
70
+ capabilities: PermissionSet[] | undefined, param: string
71
+ ): boolean => {
72
+ if (capabilities == null || capabilities.length < 1) {
73
+ return false
74
+ }
75
+ const { scope, permission, atLeast } = parseEntitlementParam(param)
76
+ if (permission === '') {
77
+ return false
78
+ }
79
+
80
+ return capabilities.some(set => {
81
+ if (scope != null && set.scope !== scope) {
82
+ return false
83
+ }
84
+ const held = set.permissions?.[permission]
85
+ if (held == null || held === false) {
86
+ return false
87
+ }
88
+
89
+ // A floor is a numeric question: a boolean flag, however true, does not answer it.
90
+ return atLeast != null ? typeof held === 'number' && held >= atLeast : true
91
+ })
92
+ }
93
+
94
+ /** Everything a union grants, formatted back into parameters. The wire shape of a client read. */
95
+ export const entitlementList = (capabilities: PermissionSet[] | undefined): string[] => {
96
+ const list: string[] = []
97
+ for (const set of capabilities ?? []) {
98
+ for (const [permission, value] of Object.entries(set.permissions ?? {})) {
99
+ if (value == null || value === false) {
100
+ continue
101
+ }
102
+ list.push(formatEntitlementParam({ scope: set.scope, permission }))
103
+ }
104
+ }
105
+
106
+ return [...new Set(list)]
107
+ }
108
+
109
+ /**
110
+ * Declare that an entrypoint needs a paid capability.
111
+ *
112
+ * Compatibility sugar over the protocol option
113
+ * `{ gate: { alias: ENTITLEMENT_GATE, params } }` so a route reads as what it means. Several
114
+ * parameters are OR'd, matching every other gate in the framework.
115
+ *
116
+ * Putting the requirement HERE rather than in a handler is the point: the framework asserts a gate
117
+ * before the handler is entered, so the route table states what a feature costs and no new
118
+ * endpoint can forget to check.
119
+ */
120
+ export const entitled = (
121
+ params: string | string[], opts?: EntrypointOptions,
122
+ ): EntrypointOptions => ({
123
+ ...opts,
124
+ gate: { alias: ENTITLEMENT_GATE, params },
125
+ })
@@ -0,0 +1,57 @@
1
+ import { contract, protocol, typed } from '@owlmeans/entrypoint'
2
+ import { route, backend, RouteMethod } from '@owlmeans/route'
3
+ import { SubscriptionPropagateBodySchema } from './model/subscription.js'
4
+ import { CreateCheckoutBodySchema, CreateCheckoutResponseSchema } from './model/checkout.js'
5
+
6
+ const aliases = {
7
+ subscription: {
8
+ base: 'payment-api:subscription',
9
+ propagate: 'payment-api:subscription:propagate',
10
+ },
11
+ service: {
12
+ base: 'payment-service:base',
13
+ checkout: 'payment-service:checkout',
14
+ session: 'payment-service:checkout:session',
15
+ external: 'payment-service:checkout:session:external',
16
+ create: 'payment-service:checkout:session:external:create',
17
+ },
18
+ } as const
19
+
20
+ const subscriptionBase = protocol(route(aliases.subscription.base, '/subscription', backend()), contract())
21
+ const serviceBase = protocol(route(aliases.service.base, '/payment-service', backend()), contract())
22
+ const checkoutBase = protocol(
23
+ route(aliases.service.checkout, '/checkout', backend({ parent: serviceBase })), contract(),
24
+ )
25
+ const sessionBase = protocol(
26
+ route(aliases.service.session, '/session', backend({ parent: checkoutBase })), contract(),
27
+ )
28
+ const externalBase = protocol(
29
+ route(aliases.service.external, '/external', backend({ parent: sessionBase })), contract(),
30
+ )
31
+
32
+ /** Public payment protocols. Alias strings stay private to this declaration module. */
33
+ export const paymentApi = {
34
+ subscription: {
35
+ base: subscriptionBase,
36
+ propagate: protocol(
37
+ route(aliases.subscription.propagate, '/propagate', backend({ parent: subscriptionBase, method: RouteMethod.POST })),
38
+ contract.request({ body: SubscriptionPropagateBodySchema }, typed<undefined>()),
39
+ ),
40
+ },
41
+ service: {
42
+ base: serviceBase,
43
+ checkout: {
44
+ base: checkoutBase,
45
+ session: {
46
+ base: sessionBase,
47
+ external: {
48
+ base: externalBase,
49
+ create: protocol(
50
+ route(aliases.service.create, '/create', backend({ parent: externalBase, method: RouteMethod.POST })),
51
+ contract.request({ body: CreateCheckoutBodySchema }, CreateCheckoutResponseSchema),
52
+ ),
53
+ },
54
+ },
55
+ },
56
+ },
57
+ } as const
package/src/index.ts CHANGED
@@ -3,7 +3,10 @@ export type * from './types.js'
3
3
  export * from './model/index.js'
4
4
  export * from './errors.js'
5
5
  export * from './consts.js'
6
+ export * from './entitlement.js'
6
7
  export * from './service.js'
7
8
  export * from './i18n.js'
8
9
  export * from './helper.js'
9
- export * from './modules.js'
10
+ export * from './entrypoints.js'
11
+ export * from './advertise.js'
12
+ export * from './pricing.js'
@@ -1,27 +1,29 @@
1
1
  import type { JSONSchemaType } from 'ajv'
2
2
  import type { CreateCheckoutBody, CreateCheckoutResponse } from '../types.js'
3
3
  import { ResourceValueSchema, EntityValueSchema, IdValueSchema } from '@owlmeans/auth'
4
+ import { schema } from '@owlmeans/entrypoint'
4
5
 
5
- export const CreateCheckoutBodySchema: JSONSchemaType<CreateCheckoutBody> = {
6
+ export const CreateCheckoutBodySchema = schema<CreateCheckoutBody>({
6
7
  type: 'object',
7
8
  properties: {
8
9
  productSku: ResourceValueSchema,
9
10
  sku: { ...ResourceValueSchema, nullable: true },
10
- entityId: EntityValueSchema,
11
+ entitySlug: EntityValueSchema,
11
12
  service: { ...ResourceValueSchema, minLength: 2 },
13
+ amountMinor: { type: 'number', minimum: 0, multipleOf: 1, nullable: true },
12
14
  subscriptionId: { ...IdValueSchema, nullable: true },
13
15
  successUrl: { ...ResourceValueSchema, minLength: 1, nullable: true },
14
16
  cancelUrl: { ...ResourceValueSchema, minLength: 1, nullable: true },
15
17
  },
16
- required: ['productSku', 'entityId', 'service'],
18
+ required: ['productSku', 'entitySlug', 'service'],
17
19
  additionalProperties: false,
18
- }
20
+ } as JSONSchemaType<CreateCheckoutBody>)
19
21
 
20
- export const CreateCheckoutResponseSchema: JSONSchemaType<CreateCheckoutResponse> = {
22
+ export const CreateCheckoutResponseSchema = schema<CreateCheckoutResponse>({
21
23
  type: 'object',
22
24
  properties: {
23
25
  url: { type: 'string', minLength: 1 },
24
26
  },
25
27
  required: ['url'],
26
28
  additionalProperties: false,
27
- }
29
+ } as JSONSchemaType<CreateCheckoutResponse>)
@@ -5,3 +5,4 @@ export * from './subscription.js'
5
5
  export * from './plan.js'
6
6
  export * from './utils.js'
7
7
  export * from './checkout.js'
8
+ export * from './pricing.js'
package/src/model/plan.ts CHANGED
@@ -2,10 +2,11 @@ import type { JSONSchemaType } from 'ajv'
2
2
  import type { ProductPlan } from '../types.js'
3
3
  import { ResourceValueSchema, PermissionSetSchema, DateSchema } from '@owlmeans/auth'
4
4
  import {
5
- PlanDurationSchema, ProductDescriptionSchema, ProductTitleSchema,
5
+ CheckoutPricingModeSchema, PlanDurationSchema, ProductDescriptionSchema, ProductTitleSchema,
6
6
  SubscriptionStatusSchema
7
7
  } from '../consts.js'
8
8
  import { LimitConfigSchema } from './utils.js'
9
+ import { AmountCheckoutPolicySchema, QuantityCheckoutPolicySchema } from './pricing.js'
9
10
 
10
11
  export const ProductPlanSchema: JSONSchemaType<ProductPlan> = {
11
12
  type: 'object',
@@ -42,6 +43,9 @@ export const ProductPlanSchema: JSONSchemaType<ProductPlan> = {
42
43
  type: 'object', required: [], nullable: true,
43
44
  additionalProperties: LimitConfigSchema
44
45
  },
46
+ pricingMode: { ...CheckoutPricingModeSchema, nullable: true },
47
+ amountPolicy: { ...AmountCheckoutPolicySchema, nullable: true },
48
+ quantityPolicy: { ...QuantityCheckoutPolicySchema, nullable: true },
45
49
  },
46
50
  required: [
47
51
  'productSku', 'sku', 'status', 'duration',
@@ -0,0 +1,34 @@
1
+ import type { JSONSchemaType } from 'ajv'
2
+ import { schema } from '@owlmeans/entrypoint'
3
+ import type { AmountCheckoutPolicy, QuantityCheckoutPolicy } from '../types.js'
4
+
5
+ export const AmountCheckoutPolicySchema = schema<AmountCheckoutPolicy>({
6
+ type: 'object',
7
+ properties: {
8
+ currency: { type: 'string', minLength: 3, maxLength: 3 },
9
+ minimumMinor: { type: 'number', minimum: 0, multipleOf: 1 },
10
+ maximumMinor: { type: 'number', minimum: 0, multipleOf: 1 },
11
+ defaultMinor: { type: 'number', minimum: 0, multipleOf: 1 },
12
+ presetsMinor: {
13
+ type: 'array', items: { type: 'number', minimum: 0, multipleOf: 1 }, uniqueItems: true,
14
+ },
15
+ fixedMinor: { type: 'number', minimum: 0, multipleOf: 1 },
16
+ rateBps: { type: 'number', minimum: 0, maximum: 9999, multipleOf: 1 },
17
+ },
18
+ required: [
19
+ 'currency', 'minimumMinor', 'maximumMinor', 'defaultMinor', 'presetsMinor',
20
+ 'fixedMinor', 'rateBps',
21
+ ],
22
+ additionalProperties: false,
23
+ } as JSONSchemaType<AmountCheckoutPolicy>)
24
+
25
+ export const QuantityCheckoutPolicySchema = schema<QuantityCheckoutPolicy>({
26
+ type: 'object',
27
+ properties: {
28
+ minimum: { type: 'number', minimum: 1, multipleOf: 1 },
29
+ maximum: { type: 'number', minimum: 1, multipleOf: 1 },
30
+ default: { type: 'number', minimum: 1, multipleOf: 1 },
31
+ },
32
+ required: ['minimum', 'maximum', 'default'],
33
+ additionalProperties: false,
34
+ } as JSONSchemaType<QuantityCheckoutPolicy>)
@@ -1,6 +1,7 @@
1
1
  import type { JSONSchemaType } from 'ajv'
2
2
  import type { PlanSubscription, SubscriptionPropagateBody } from '../types.js'
3
3
  import { ResourceValueSchema, PermissionSetSchema, DateSchema, EntityValueSchema, IdValueSchema } from '@owlmeans/auth'
4
+ import { schema } from '@owlmeans/entrypoint'
4
5
  import { SubscriptionStatusSchema } from '../consts.js'
5
6
  import { CapabilityUsageSchema, LimitConfigSchema } from './utils.js'
6
7
 
@@ -37,27 +38,23 @@ export const PlanSubscriptionSchema: JSONSchemaType<PlanSubscription> = {
37
38
  additionalProperties: false,
38
39
  }
39
40
 
40
- export const SubscriptionPropagateBodySchema: JSONSchemaType<SubscriptionPropagateBody> = {
41
+ const { entityId: _entityId, ...SubscriptionWireProperties } =
42
+ PlanSubscriptionSchema.properties as Record<string, unknown>
43
+ const SubscriptionWireRequired = PlanSubscriptionSchema.required.filter(field => field !== 'entityId')
44
+
45
+ export const SubscriptionPropagateBodySchema = schema<SubscriptionPropagateBody>({
41
46
  type: 'object',
42
47
  properties: {
43
- ...PlanSubscriptionSchema.properties,
48
+ ...SubscriptionWireProperties,
49
+ entitySlug: EntityValueSchema,
44
50
  service: { ...ResourceValueSchema, minLength: 2 },
45
51
  externalId: IdValueSchema,
46
52
  } as any, // @TODO Figure out why it doesn't work (probably different version of ajv)
47
- required: ['externalId', 'service', ...PlanSubscriptionSchema.required],
53
+ required: ['externalId', 'entitySlug', 'service', ...SubscriptionWireRequired],
48
54
  additionalProperties: false,
49
- }
55
+ } as JSONSchemaType<SubscriptionPropagateBody>)
50
56
 
51
57
  /**
52
58
  * @deprecated Use SubscriptionPropogateBodySchema instead
53
59
  */
54
- export const SubscriptionPropogateBodySchema: JSONSchemaType<SubscriptionPropagateBody> = {
55
- type: 'object',
56
- properties: {
57
- ...PlanSubscriptionSchema.properties,
58
- service: { ...ResourceValueSchema, minLength: 2 },
59
- externalId: IdValueSchema,
60
- } as any, // @TODO Figure out why it doesn't work (probably different version of ajv)
61
- required: ['externalId', 'service', ...PlanSubscriptionSchema.required],
62
- additionalProperties: false,
63
- }
60
+ export const SubscriptionPropogateBodySchema = SubscriptionPropagateBodySchema
package/src/pricing.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { PaymentError } from './errors.js'
2
+ import type { AmountCheckoutPolicy, QuantityCheckoutPolicy } from './types.js'
3
+
4
+ const integer = (value: number): boolean => Number.isSafeInteger(value)
5
+
6
+ export const assertAmountCheckoutPolicy = (
7
+ policy: AmountCheckoutPolicy,
8
+ ): AmountCheckoutPolicy => {
9
+ if (
10
+ !integer(policy.minimumMinor)
11
+ || !integer(policy.maximumMinor)
12
+ || !integer(policy.defaultMinor)
13
+ || !integer(policy.fixedMinor)
14
+ || !integer(policy.rateBps)
15
+ || policy.minimumMinor < 0
16
+ || policy.minimumMinor > policy.defaultMinor
17
+ || policy.defaultMinor > policy.maximumMinor
18
+ || policy.fixedMinor < 0
19
+ || policy.rateBps < 0
20
+ || policy.rateBps >= 10_000
21
+ || policy.currency.trim().length !== 3
22
+ ) {
23
+ throw new PaymentError('checkout-policy:amount')
24
+ }
25
+
26
+ const presets = new Set(policy.presetsMinor)
27
+ if (
28
+ presets.size !== policy.presetsMinor.length
29
+ || policy.presetsMinor.some(value =>
30
+ !integer(value) || value < policy.minimumMinor || value > policy.maximumMinor
31
+ )
32
+ ) {
33
+ throw new PaymentError('checkout-policy:presets')
34
+ }
35
+
36
+ return policy
37
+ }
38
+
39
+ export const assertQuantityCheckoutPolicy = (
40
+ policy: QuantityCheckoutPolicy,
41
+ ): QuantityCheckoutPolicy => {
42
+ if (
43
+ !integer(policy.minimum)
44
+ || !integer(policy.maximum)
45
+ || !integer(policy.default)
46
+ || policy.minimum < 1
47
+ || policy.minimum > policy.default
48
+ || policy.default > policy.maximum
49
+ ) {
50
+ throw new PaymentError('checkout-policy:quantity')
51
+ }
52
+ return policy
53
+ }
54
+
55
+ export const assertCheckoutAmount = (
56
+ policy: AmountCheckoutPolicy, amountMinor: number,
57
+ ): number => {
58
+ assertAmountCheckoutPolicy(policy)
59
+ if (!integer(amountMinor)) {
60
+ throw new PaymentError('checkout-amount:integer')
61
+ }
62
+ if (amountMinor < policy.minimumMinor) {
63
+ throw new PaymentError('checkout-amount:minimum')
64
+ }
65
+ if (amountMinor > policy.maximumMinor) {
66
+ throw new PaymentError('checkout-amount:maximum')
67
+ }
68
+ return amountMinor
69
+ }
70
+
71
+ /** Gross up a net credit value so the configured adjustment remains outside the credit grant. */
72
+ export const chargeAmountMinor = (
73
+ amountMinor: number, policy: Pick<AmountCheckoutPolicy, 'fixedMinor' | 'rateBps'>,
74
+ ): number => {
75
+ const adjusted = amountMinor + policy.fixedMinor
76
+ const numerator = adjusted * 10_000
77
+ if (
78
+ !integer(amountMinor) || !integer(policy.fixedMinor) || !integer(policy.rateBps)
79
+ || amountMinor < 0 || policy.fixedMinor < 0 || policy.rateBps < 0 || policy.rateBps >= 10_000
80
+ || !integer(adjusted) || !integer(numerator)
81
+ ) {
82
+ throw new PaymentError('checkout-charge')
83
+ }
84
+ const charge = Math.ceil(numerator / (10_000 - policy.rateBps))
85
+ if (!integer(charge)) throw new PaymentError('checkout-charge')
86
+ return charge
87
+ }
package/src/types.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { InitializedService } from '@owlmeans/context'
2
2
 
3
- import type { PaymentEntityType, PlanDuration, PlanStatus, ProductType, SubscriptionStatus } from './consts.js'
3
+ import type {
4
+ CheckoutPricingMode, PaymentEntityType, PlanDuration, PlanStatus, ProductType, SubscriptionStatus
5
+ } from './consts.js'
4
6
  import type { PermissionSet } from '@owlmeans/auth'
5
7
 
6
8
  export interface Product {
@@ -58,6 +60,25 @@ export interface ProductPlan {
58
60
  supsendedAt?: Date
59
61
  capabilities?: PermissionSet[]
60
62
  limits?: { [key: string]: LimitConfig }
63
+ pricingMode?: CheckoutPricingMode
64
+ amountPolicy?: AmountCheckoutPolicy
65
+ quantityPolicy?: QuantityCheckoutPolicy
66
+ }
67
+
68
+ export interface AmountCheckoutPolicy {
69
+ currency: string
70
+ minimumMinor: number
71
+ maximumMinor: number
72
+ defaultMinor: number
73
+ presetsMinor: number[]
74
+ fixedMinor: number
75
+ rateBps: number
76
+ }
77
+
78
+ export interface QuantityCheckoutPolicy {
79
+ minimum: number
80
+ maximum: number
81
+ default: number
61
82
  }
62
83
 
63
84
  export interface PlanSubscription {
@@ -91,8 +112,11 @@ export interface PlanSubscription {
91
112
  export interface CreateCheckoutBody {
92
113
  productSku: string
93
114
  sku?: string
94
- entityId: string
115
+ /** Renameable organization value carried across a protocol boundary. */
116
+ entitySlug: string
95
117
  service: string
118
+ /** Net credit value for amount-priced checkouts, in integer currency minor units. */
119
+ amountMinor?: number
96
120
  subscriptionId?: string
97
121
  successUrl?: string
98
122
  cancelUrl?: string
@@ -102,7 +126,9 @@ export interface CreateCheckoutResponse {
102
126
  url: string
103
127
  }
104
128
 
105
- export interface SubscriptionPropagateBody extends PlanSubscription {
129
+ export interface SubscriptionPropagateBody extends Omit<PlanSubscription, 'entityId' | 'externalId'> {
130
+ /** Renameable organization value carried across the propagation protocol. */
131
+ entitySlug: string
106
132
  service: string
107
133
  externalId: string
108
134
  }
@@ -0,0 +1,90 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import {
3
+ CAPABILITY_FEATURE_SCOPE, entitlementList, formatEntitlementParam, hasEntitlement,
4
+ parseEntitlementParam,
5
+ } from '../src/entitlement.js'
6
+ import type { PermissionSet } from '@owlmeans/auth'
7
+
8
+ const pro: PermissionSet[] = [
9
+ { scope: 'renewable', permissions: { production: 1, credits: 5000 } },
10
+ { scope: CAPABILITY_FEATURE_SCOPE, permissions: { 'branding--whitelabel': true, 'domain--custom': true } },
11
+ ]
12
+
13
+ describe('the parameter grammar', () => {
14
+ test('a bare permission has no scope and no floor', () => {
15
+ expect(parseEntitlementParam('domain--custom')).toEqual({ permission: 'domain--custom' })
16
+ })
17
+
18
+ test('a scope is everything before the first colon', () => {
19
+ expect(parseEntitlementParam('feature:branding--whitelabel'))
20
+ .toEqual({ scope: 'feature', permission: 'branding--whitelabel' })
21
+ })
22
+
23
+ test('a floor is parsed as a number', () => {
24
+ expect(parseEntitlementParam('renewable:credits>=100'))
25
+ .toEqual({ scope: 'renewable', permission: 'credits', atLeast: 100 })
26
+ })
27
+
28
+ test('formatting round-trips', () => {
29
+ for (const param of ['domain--custom', 'feature:branding--whitelabel', 'renewable:credits>=100']) {
30
+ expect(formatEntitlementParam(parseEntitlementParam(param))).toBe(param)
31
+ }
32
+ })
33
+ })
34
+
35
+ describe('the predicate', () => {
36
+ test('a held feature passes', () => {
37
+ expect(hasEntitlement(pro, 'feature:branding--whitelabel')).toBe(true)
38
+ })
39
+
40
+ test('a feature nobody granted does not', () => {
41
+ expect(hasEntitlement(pro, 'feature:branding--nonexistent')).toBe(false)
42
+ })
43
+
44
+ test('a scope that does not match is not consulted', () => {
45
+ expect(hasEntitlement(pro, 'renewable:branding--whitelabel')).toBe(false)
46
+ // …and without a scope, any set may answer.
47
+ expect(hasEntitlement(pro, 'branding--whitelabel')).toBe(true)
48
+ })
49
+
50
+ test('a numeric floor compares', () => {
51
+ expect(hasEntitlement(pro, 'renewable:credits>=5000')).toBe(true)
52
+ expect(hasEntitlement(pro, 'renewable:credits>=5001')).toBe(false)
53
+ })
54
+
55
+ test('a boolean cannot satisfy a numeric floor', () => {
56
+ expect(hasEntitlement(pro, 'feature:branding--whitelabel>=1')).toBe(false)
57
+ })
58
+
59
+ test('an explicit false is not a grant', () => {
60
+ expect(hasEntitlement([{ scope: 'feature', permissions: { x: false } }], 'feature:x')).toBe(false)
61
+ expect(hasEntitlement([{ scope: 'feature', permissions: { x: null } }], 'feature:x')).toBe(false)
62
+ })
63
+
64
+ test('no capabilities at all is a refusal, never a pass', () => {
65
+ expect(hasEntitlement(undefined, 'feature:branding--whitelabel')).toBe(false)
66
+ expect(hasEntitlement([], 'feature:branding--whitelabel')).toBe(false)
67
+ })
68
+
69
+ test('a malformed parameter refuses rather than throwing', () => {
70
+ // A gate that crashed on a typo would take down the endpoint it guards.
71
+ expect(() => hasEntitlement(pro, '')).not.toThrow()
72
+ expect(hasEntitlement(pro, '')).toBe(false)
73
+ expect(hasEntitlement(pro, 'feature:')).toBe(false)
74
+ expect(hasEntitlement(pro, 'renewable:credits>=abc')).toBe(true)
75
+ })
76
+ })
77
+
78
+ describe('listing', () => {
79
+ test('unions every granted capability, scoped', () => {
80
+ expect(entitlementList(pro).sort()).toEqual([
81
+ 'feature:branding--whitelabel', 'feature:domain--custom',
82
+ 'renewable:credits', 'renewable:production',
83
+ ])
84
+ })
85
+
86
+ test('skips what is not granted', () => {
87
+ expect(entitlementList([{ scope: 'feature', permissions: { a: true, b: false, c: null } }]))
88
+ .toEqual(['feature:a'])
89
+ })
90
+ })
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ assertAmountCheckoutPolicy, assertCheckoutAmount, chargeAmountMinor,
4
+ type AmountCheckoutPolicy,
5
+ } from '../src/index.js'
6
+
7
+ const policy: AmountCheckoutPolicy = {
8
+ currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 1_000,
9
+ presetsMinor: [1_000, 2_000, 5_000, 10_000], fixedMinor: 0, rateBps: 200,
10
+ }
11
+
12
+ describe('amount checkout pricing', () => {
13
+ test('accepts exact boundaries and custom cents', () => {
14
+ expect(assertCheckoutAmount(policy, 500)).toBe(500)
15
+ expect(assertCheckoutAmount(policy, 501)).toBe(501)
16
+ expect(assertCheckoutAmount(policy, 50_000)).toBe(50_000)
17
+ })
18
+
19
+ test('rejects values outside the net-credit bounds', () => {
20
+ expect(() => assertCheckoutAmount(policy, 499)).toThrow()
21
+ expect(() => assertCheckoutAmount(policy, 50_001)).toThrow()
22
+ })
23
+
24
+ test('grosses ten dollars up to ten dollars and twenty-one cents', () => {
25
+ expect(chargeAmountMinor(1_000, policy)).toBe(1_021)
26
+ })
27
+
28
+ test('rejects invalid policy ordering, duplicate presets, and a 100% rate', () => {
29
+ expect(() => assertAmountCheckoutPolicy({ ...policy, defaultMinor: 100 })).toThrow()
30
+ expect(() => assertAmountCheckoutPolicy({ ...policy, presetsMinor: [1_000, 1_000] })).toThrow()
31
+ expect(() => assertAmountCheckoutPolicy({ ...policy, rateBps: 10_000 })).toThrow()
32
+ })
33
+ })
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import Ajv from 'ajv'
3
+ import { protocols } from '@owlmeans/entrypoint'
4
+ import type { RequestOf, ResponseOf } from '@owlmeans/entrypoint'
5
+ import * as payment from '../src/index.js'
6
+ import { paymentApi } from '../src/index.js'
7
+
8
+ const checkout = paymentApi.service.checkout.session.external.create
9
+
10
+ describe('payment checkout protocol', () => {
11
+ test('infers the organization slug and integer amount request', () => {
12
+ const request: RequestOf<typeof checkout> = {
13
+ body: { productSku: 'vib-credits', entitySlug: 'acme', service: 'app', amountMinor: 1_001 },
14
+ }
15
+ const response: ResponseOf<typeof checkout> = { url: 'https://checkout.stripe.test/session' }
16
+
17
+ expect(request.body.entitySlug).toBe('acme')
18
+ expect(request.body.amountMinor).toBe(1_001)
19
+ expect(response.url).toStartWith('https://')
20
+ })
21
+
22
+ test('exposes protocol declarations, never flattened string aliases', () => {
23
+ expect(Object.isFrozen(checkout)).toBe(true)
24
+ expect(protocols(paymentApi.service)).toContain(checkout)
25
+ expect(protocols(paymentApi.service).every(entry => entry.kind === 'entrypoint-protocol')).toBe(true)
26
+ expect(payment).not.toHaveProperty('serviceEntrypoints')
27
+ expect(payment).not.toHaveProperty('entrypoints')
28
+ })
29
+
30
+ test('rejects an internal entity id and fractional minor units on the wire', () => {
31
+ const ajv = new Ajv({ strict: false, validateFormats: false })
32
+ const validate = ajv.compile(checkout.contract!.requestSchemas.body!)
33
+
34
+ expect(validate({ productSku: 'vib-credits', entitySlug: 'acme', service: 'app', amountMinor: 500 })).toBe(true)
35
+ expect(validate({ productSku: 'vib-credits', entityId: 'internal', service: 'app', amountMinor: 500 })).toBe(false)
36
+ expect(validate({ productSku: 'vib-credits', entitySlug: 'acme', service: 'app', amountMinor: 500.5 })).toBe(false)
37
+ })
38
+
39
+ test('uses an organization slug on subscription propagation too', () => {
40
+ const ajv = new Ajv({ strict: false, validateFormats: false })
41
+ const validate = ajv.compile(paymentApi.subscription.propagate.contract!.requestSchemas.body!)
42
+ const body = {
43
+ sku: 'pro-plan', entitySlug: 'acme', service: 'app', externalId: 'sub_1',
44
+ createdAt: new Date(), startsdAt: new Date(), status: 'active',
45
+ }
46
+
47
+ expect(validate(body)).toBe(true)
48
+ expect(validate({ ...body, entitySlug: undefined, entityId: 'internal' })).toBe(false)
49
+ expect(paymentApi.subscription).not.toHaveProperty('propogate')
50
+ })
51
+ })