@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.
- package/README.md +18 -26
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/payment/SKILL.md +137 -26
- package/build/advertise.d.ts +2 -0
- package/build/advertise.d.ts.map +1 -0
- package/build/advertise.js +11 -0
- package/build/advertise.js.map +1 -0
- package/build/consts.d.ts +5 -23
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +9 -23
- package/build/consts.js.map +1 -1
- package/build/entitlement.d.ts +61 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +98 -0
- package/build/entitlement.js.map +1 -0
- package/build/entrypoints.d.ts +25 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +43 -0
- package/build/entrypoints.js.map +1 -0
- package/build/index.d.ts +4 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -1
- package/build/index.js.map +1 -1
- package/build/model/checkout.d.ts +2 -3
- package/build/model/checkout.d.ts.map +1 -1
- package/build/model/checkout.js +8 -6
- package/build/model/checkout.js.map +1 -1
- package/build/model/index.d.ts +1 -0
- package/build/model/index.d.ts.map +1 -1
- package/build/model/index.js +1 -0
- package/build/model/index.js.map +1 -1
- package/build/model/plan.d.ts.map +1 -1
- package/build/model/plan.js +5 -1
- package/build/model/plan.js.map +1 -1
- package/build/model/pricing.d.ts +4 -0
- package/build/model/pricing.d.ts.map +1 -0
- package/build/model/pricing.js +31 -0
- package/build/model/pricing.js.map +1 -0
- package/build/model/subscription.d.ts +2 -2
- package/build/model/subscription.d.ts.map +1 -1
- package/build/model/subscription.js +9 -14
- package/build/model/subscription.js.map +1 -1
- package/build/pricing.d.ts +7 -0
- package/build/pricing.d.ts.map +1 -0
- package/build/pricing.js +63 -0
- package/build/pricing.js.map +1 -0
- package/build/types.d.ts +25 -3
- package/build/types.d.ts.map +1 -1
- package/package.json +13 -11
- package/src/advertise.ts +15 -0
- package/src/consts.ts +10 -24
- package/src/entitlement.ts +125 -0
- package/src/entrypoints.ts +57 -0
- package/src/index.ts +4 -1
- package/src/model/checkout.ts +8 -6
- package/src/model/index.ts +1 -0
- package/src/model/plan.ts +5 -1
- package/src/model/pricing.ts +34 -0
- package/src/model/subscription.ts +11 -14
- package/src/pricing.ts +87 -0
- package/src/types.ts +29 -3
- package/tests/entitlement.spec.ts +90 -0
- package/tests/pricing.spec.ts +33 -0
- package/tests/protocol.spec.ts +51 -0
- package/tests/tsconfig.json +11 -0
- package/tsconfig.json +6 -1
- package/build/.gitkeep +0 -0
- package/build/modules.d.ts +0 -3
- package/build/modules.d.ts.map +0 -1
- package/build/modules.js +0 -18
- package/build/modules.js.map +0 -1
- 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 './
|
|
10
|
+
export * from './entrypoints.js'
|
|
11
|
+
export * from './advertise.js'
|
|
12
|
+
export * from './pricing.js'
|
package/src/model/checkout.ts
CHANGED
|
@@ -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
|
|
6
|
+
export const CreateCheckoutBodySchema = schema<CreateCheckoutBody>({
|
|
6
7
|
type: 'object',
|
|
7
8
|
properties: {
|
|
8
9
|
productSku: ResourceValueSchema,
|
|
9
10
|
sku: { ...ResourceValueSchema, nullable: true },
|
|
10
|
-
|
|
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', '
|
|
18
|
+
required: ['productSku', 'entitySlug', 'service'],
|
|
17
19
|
additionalProperties: false,
|
|
18
|
-
}
|
|
20
|
+
} as JSONSchemaType<CreateCheckoutBody>)
|
|
19
21
|
|
|
20
|
-
export const CreateCheckoutResponseSchema
|
|
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>)
|
package/src/model/index.ts
CHANGED
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
|
-
|
|
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
|
-
...
|
|
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', ...
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
+
})
|