@vouchington/memberships 0.0.0
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/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/catalog.d.mts +23 -0
- package/dist/catalog.mjs +34 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +3 -0
- package/dist/lifecycle.d.mts +12 -0
- package/dist/lifecycle.mjs +56 -0
- package/dist/provider.d.mts +53 -0
- package/dist/provider.mjs +1 -0
- package/dist/sku.d.mts +3 -0
- package/dist/sku.mjs +11 -0
- package/dist/types.d.mts +14 -0
- package/dist/types.mjs +1 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Jonathan Ong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @vouchington/memberships
|
|
2
|
+
|
|
3
|
+
Dependency-free, schema-less primitives for membership lifecycles, SKU grouping, benefit catalogs,
|
|
4
|
+
and payment-provider adapters.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import {
|
|
8
|
+
buildMembershipBenefitCatalog,
|
|
9
|
+
transitionMembershipLifecycle,
|
|
10
|
+
} from '@vouchington/memberships'
|
|
11
|
+
|
|
12
|
+
const catalog = buildMembershipBenefitCatalog(
|
|
13
|
+
{
|
|
14
|
+
version: 1,
|
|
15
|
+
plans: ['basic', 'premium'] as const,
|
|
16
|
+
groups: [
|
|
17
|
+
{
|
|
18
|
+
id: 'usage',
|
|
19
|
+
benefits: [
|
|
20
|
+
{
|
|
21
|
+
id: 'requests',
|
|
22
|
+
placements: ['comparison'] as const,
|
|
23
|
+
values: { basic: 10, premium: 100 },
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
new Set(['requests'] as const),
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The package fixes only the lifecycle vocabulary: `active`, `past_due`, `paused`, `cancelled`, and
|
|
34
|
+
`expired`. It does not define database tables, products, entitlements, authorization, payment
|
|
35
|
+
processors, hosted portals, or refund persistence. Applications own their plans, IDs, money policy,
|
|
36
|
+
provider-specific adapters, webhook authentication, and access enforcement.
|
|
37
|
+
|
|
38
|
+
Provider capabilities are structural interfaces. An application supplies its own adapter context and
|
|
39
|
+
request/result types for subscription creation, updates, cancellation, webhook normalization,
|
|
40
|
+
refundable-payment lookup, and refunds.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type MembershipBenefitCatalogInput = {
|
|
2
|
+
version: number;
|
|
3
|
+
plans: readonly PropertyKey[];
|
|
4
|
+
groups: readonly {
|
|
5
|
+
id: PropertyKey;
|
|
6
|
+
benefits: readonly {
|
|
7
|
+
id: PropertyKey;
|
|
8
|
+
placements: readonly PropertyKey[];
|
|
9
|
+
values: object;
|
|
10
|
+
}[];
|
|
11
|
+
}[];
|
|
12
|
+
};
|
|
13
|
+
type CatalogBenefit<Catalog extends MembershipBenefitCatalogInput> = Catalog['groups'][number]['benefits'][number];
|
|
14
|
+
type CatalogBenefitId<Catalog extends MembershipBenefitCatalogInput> = CatalogBenefit<Catalog>['id'];
|
|
15
|
+
type CatalogBenefitForId<Catalog extends MembershipBenefitCatalogInput, BenefitId extends CatalogBenefitId<Catalog>> = Extract<CatalogBenefit<Catalog>, {
|
|
16
|
+
id: BenefitId;
|
|
17
|
+
}>;
|
|
18
|
+
type CatalogBenefitValue<Catalog extends MembershipBenefitCatalogInput, BenefitId extends CatalogBenefitId<Catalog>, Plan extends Catalog['plans'][number]> = CatalogBenefitForId<Catalog, BenefitId> extends {
|
|
19
|
+
values: infer Values;
|
|
20
|
+
} ? Values extends Record<PropertyKey, unknown> ? Values[Plan & keyof Values] : never : never;
|
|
21
|
+
export declare function buildMembershipBenefitCatalog<const Catalog extends MembershipBenefitCatalogInput>(catalog: Catalog, enforcedBenefitIds: ReadonlySet<CatalogBenefitId<Catalog>>): Catalog;
|
|
22
|
+
export declare function resolveMembershipBenefit<const Catalog extends MembershipBenefitCatalogInput, BenefitId extends CatalogBenefitId<Catalog>, Plan extends Catalog['plans'][number]>(catalog: Catalog, benefitId: BenefitId, plan: Plan): CatalogBenefitValue<Catalog, BenefitId, Plan> | undefined;
|
|
23
|
+
export {};
|
package/dist/catalog.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function buildMembershipBenefitCatalog(catalog, enforcedBenefitIds) {
|
|
2
|
+
const groupIds = new Set();
|
|
3
|
+
const ids = new Set();
|
|
4
|
+
for (const group of catalog.groups) {
|
|
5
|
+
if (groupIds.has(group.id))
|
|
6
|
+
throw new Error(`Duplicate membership benefit group: ${String(group.id)}`);
|
|
7
|
+
groupIds.add(group.id);
|
|
8
|
+
for (const benefit of group.benefits) {
|
|
9
|
+
if (!enforcedBenefitIds.has(benefit.id)) {
|
|
10
|
+
throw new Error(`Membership benefit is not enforced: ${String(benefit.id)}`);
|
|
11
|
+
}
|
|
12
|
+
if (ids.has(benefit.id))
|
|
13
|
+
throw new Error(`Duplicate membership benefit: ${String(benefit.id)}`);
|
|
14
|
+
ids.add(benefit.id);
|
|
15
|
+
for (const plan of catalog.plans) {
|
|
16
|
+
if (!Object.hasOwn(benefit.values, plan)) {
|
|
17
|
+
throw new Error(`Membership benefit is missing a value for plan: ${String(plan)}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return catalog;
|
|
23
|
+
}
|
|
24
|
+
export function resolveMembershipBenefit(catalog, benefitId, plan) {
|
|
25
|
+
for (const group of catalog.groups) {
|
|
26
|
+
const benefit = group.benefits.find((item) => item.id === benefitId);
|
|
27
|
+
if (!benefit)
|
|
28
|
+
continue;
|
|
29
|
+
if (!Object.hasOwn(benefit.values, plan))
|
|
30
|
+
return undefined;
|
|
31
|
+
return benefit.values[plan];
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { buildMembershipBenefitCatalog, resolveMembershipBenefit } from './catalog.mts';
|
|
2
|
+
export { classifyMembershipChange, isTerminalMembershipStatus, transitionMembershipLifecycle, } from './lifecycle.mts';
|
|
3
|
+
export { groupMembershipSkusByPlan } from './sku.mts';
|
|
4
|
+
export type { CancelSubscriptionCapability, CreateSubscriptionCapability, ListRefundablePaymentsCapability, MembershipMoney, MembershipProviderOperation, NormalizedMembershipWebhook, NormalizedRefundWebhook, NormalizedSubscriptionWebhook, NormalizeWebhookCapability, RefundablePayment, RefundPaymentCapability, UpdateSubscriptionCapability, } from './provider.mts';
|
|
5
|
+
export type { MembershipBenefitCatalogInput } from './catalog.mts';
|
|
6
|
+
export type { MembershipChangeType, MembershipLifecycleFields, MembershipLifecycleUpdate, MembershipStatus, } from './types.mts';
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MembershipChangeType, MembershipLifecycleFields, MembershipLifecycleUpdate, MembershipStatus } from './types.mts';
|
|
2
|
+
export declare function isTerminalMembershipStatus(status: MembershipStatus): boolean;
|
|
3
|
+
export declare function transitionMembershipLifecycle(current: MembershipLifecycleFields, update: MembershipLifecycleUpdate, at: Date): MembershipLifecycleFields;
|
|
4
|
+
export declare function classifyMembershipChange<Plan, Sku>(options: {
|
|
5
|
+
previousStatus: MembershipStatus;
|
|
6
|
+
nextStatus: MembershipStatus;
|
|
7
|
+
previousPlan: Plan;
|
|
8
|
+
nextPlan: Plan;
|
|
9
|
+
previousSku: Sku;
|
|
10
|
+
nextSku: Sku;
|
|
11
|
+
comparePlans: (left: Plan, right: Plan) => number;
|
|
12
|
+
}): MembershipChangeType | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function isTerminalMembershipStatus(status) {
|
|
2
|
+
return status === 'cancelled' || status === 'expired';
|
|
3
|
+
}
|
|
4
|
+
export function transitionMembershipLifecycle(current, update, at) {
|
|
5
|
+
const status = update.status ?? current.status;
|
|
6
|
+
if (current.status === 'expired' && status !== 'expired') {
|
|
7
|
+
throw new Error('An expired membership cannot transition to another status');
|
|
8
|
+
}
|
|
9
|
+
const timestamp = status === 'active'
|
|
10
|
+
? null
|
|
11
|
+
: current.status === status
|
|
12
|
+
? (timestampForStatus(current, status) ?? at)
|
|
13
|
+
: at;
|
|
14
|
+
return {
|
|
15
|
+
status,
|
|
16
|
+
cancelledAt: status === 'cancelled' ? timestamp : null,
|
|
17
|
+
expiredAt: status === 'expired' ? timestamp : null,
|
|
18
|
+
pastDueAt: status === 'past_due' ? timestamp : null,
|
|
19
|
+
pausedAt: status === 'paused' ? timestamp : null,
|
|
20
|
+
cancelAtPeriodEnd: isTerminalMembershipStatus(status)
|
|
21
|
+
? false
|
|
22
|
+
: (update.cancelAtPeriodEnd ?? current.cancelAtPeriodEnd),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function classifyMembershipChange(options) {
|
|
26
|
+
const { previousStatus, nextStatus, previousPlan, nextPlan, previousSku, nextSku, comparePlans } = options;
|
|
27
|
+
if (previousPlan !== nextPlan) {
|
|
28
|
+
const comparison = comparePlans(nextPlan, previousPlan);
|
|
29
|
+
if (comparison !== 0)
|
|
30
|
+
return comparison > 0 ? 'upgrade' : 'downgrade';
|
|
31
|
+
}
|
|
32
|
+
if (previousStatus !== nextStatus)
|
|
33
|
+
return classifyStatusChange(previousStatus, nextStatus);
|
|
34
|
+
return previousSku !== nextSku ? 'sku_migration' : null;
|
|
35
|
+
}
|
|
36
|
+
function timestampForStatus(current, status) {
|
|
37
|
+
switch (status) {
|
|
38
|
+
case 'cancelled':
|
|
39
|
+
return current.cancelledAt;
|
|
40
|
+
case 'expired':
|
|
41
|
+
return current.expiredAt;
|
|
42
|
+
case 'past_due':
|
|
43
|
+
return current.pastDueAt;
|
|
44
|
+
case 'paused':
|
|
45
|
+
return current.pausedAt;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function classifyStatusChange(previous, next) {
|
|
49
|
+
if (next === 'cancelled')
|
|
50
|
+
return 'cancellation';
|
|
51
|
+
if (next === 'paused')
|
|
52
|
+
return 'pause';
|
|
53
|
+
if ((previous === 'cancelled' || previous === 'paused') && next === 'active')
|
|
54
|
+
return 'reactivation';
|
|
55
|
+
return next === 'expired' ? 'expiration' : 'renewal';
|
|
56
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { MembershipStatus } from './types.mts';
|
|
2
|
+
export type MembershipMoney = {
|
|
3
|
+
amountMinorUnits: number;
|
|
4
|
+
currency: string;
|
|
5
|
+
};
|
|
6
|
+
/** Caller-generated key used by provider adapters to make mutations replay-safe. */
|
|
7
|
+
export type MembershipProviderOperation = {
|
|
8
|
+
idempotencyKey: string;
|
|
9
|
+
};
|
|
10
|
+
export type NormalizedSubscriptionWebhook<SubscriptionId = string, CustomerId = string, SkuId = string> = {
|
|
11
|
+
kind: 'subscription';
|
|
12
|
+
eventId: string;
|
|
13
|
+
occurredAt: Date;
|
|
14
|
+
subscriptionId: SubscriptionId;
|
|
15
|
+
customerId: CustomerId | null;
|
|
16
|
+
skuId: SkuId | null;
|
|
17
|
+
status: MembershipStatus;
|
|
18
|
+
periodEndAt: Date | null;
|
|
19
|
+
cancelAtPeriodEnd: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type NormalizedRefundWebhook<PaymentId = string> = {
|
|
22
|
+
kind: 'refund';
|
|
23
|
+
eventId: string;
|
|
24
|
+
occurredAt: Date;
|
|
25
|
+
paymentId: PaymentId;
|
|
26
|
+
amount: MembershipMoney;
|
|
27
|
+
};
|
|
28
|
+
export type NormalizedMembershipWebhook<SubscriptionId = string, CustomerId = string, SkuId = string, PaymentId = string> = NormalizedSubscriptionWebhook<SubscriptionId, CustomerId, SkuId> | NormalizedRefundWebhook<PaymentId>;
|
|
29
|
+
export type RefundablePayment<PaymentId = string> = {
|
|
30
|
+
paymentId: PaymentId;
|
|
31
|
+
amount: MembershipMoney;
|
|
32
|
+
refundedAmount: MembershipMoney;
|
|
33
|
+
createdAt: Date;
|
|
34
|
+
description: string | null;
|
|
35
|
+
};
|
|
36
|
+
export type CreateSubscriptionCapability<Context, Request, Result> = {
|
|
37
|
+
createSubscription(context: Context, request: Request, operation: MembershipProviderOperation): Promise<Result>;
|
|
38
|
+
};
|
|
39
|
+
export type UpdateSubscriptionCapability<Context, Request, Result> = {
|
|
40
|
+
updateSubscription(context: Context, request: Request, operation: MembershipProviderOperation): Promise<Result>;
|
|
41
|
+
};
|
|
42
|
+
export type CancelSubscriptionCapability<Context, Request, Result> = {
|
|
43
|
+
cancelSubscription(context: Context, request: Request, operation: MembershipProviderOperation): Promise<Result>;
|
|
44
|
+
};
|
|
45
|
+
export type NormalizeWebhookCapability<Context, Request, SubscriptionId = string, CustomerId = string, SkuId = string, PaymentId = string> = {
|
|
46
|
+
normalizeWebhook(context: Context, request: Request): Promise<NormalizedMembershipWebhook<SubscriptionId, CustomerId, SkuId, PaymentId>>;
|
|
47
|
+
};
|
|
48
|
+
export type ListRefundablePaymentsCapability<Context, Request, PaymentId = string> = {
|
|
49
|
+
listRefundablePayments(context: Context, request: Request): Promise<RefundablePayment<PaymentId>[]>;
|
|
50
|
+
};
|
|
51
|
+
export type RefundPaymentCapability<Context, Request, Result> = {
|
|
52
|
+
refundPayment(context: Context, request: Request, operation: MembershipProviderOperation): Promise<Result>;
|
|
53
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/sku.d.mts
ADDED
package/dist/sku.mjs
ADDED
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type MembershipStatus = 'active' | 'past_due' | 'paused' | 'cancelled' | 'expired';
|
|
2
|
+
export type MembershipLifecycleFields = {
|
|
3
|
+
status: MembershipStatus;
|
|
4
|
+
cancelledAt: Date | null;
|
|
5
|
+
expiredAt: Date | null;
|
|
6
|
+
pastDueAt: Date | null;
|
|
7
|
+
pausedAt: Date | null;
|
|
8
|
+
cancelAtPeriodEnd: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type MembershipLifecycleUpdate = {
|
|
11
|
+
status?: MembershipStatus;
|
|
12
|
+
cancelAtPeriodEnd?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type MembershipChangeType = 'upgrade' | 'downgrade' | 'renewal' | 'cancellation' | 'reactivation' | 'pause' | 'sku_migration' | 'expiration';
|
package/dist/types.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vouchington/memberships",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Schema-less membership lifecycle, catalog, SKU, and provider primitives.",
|
|
5
|
+
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/memberships#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/vouchington/vouchington-platform/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Jonathan Ong",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/vouchington/vouchington-platform.git",
|
|
14
|
+
"directory": "packages/memberships"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./dist/index.mjs",
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"import": "./dist/index.mjs",
|
|
28
|
+
"default": "./dist/index.mjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc --project tsconfig.build.json",
|
|
36
|
+
"prepack": "pnpm run build"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=24.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|