@dineway-ai/plugin-payment 0.2.3 → 0.2.5
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/package.json +4 -3
- package/src/admin/subscriptions.tsx +514 -0
- package/src/admin.tsx +5 -1
- package/src/configuration/secrets.ts +3 -1
- package/src/index.ts +23 -4
- package/src/stripe/webhook-verifier.ts +11 -0
- package/src/subscriptions/format.ts +12 -0
- package/src/subscriptions/index.ts +6 -0
- package/src/subscriptions/projection.ts +185 -0
- package/src/subscriptions/repository.ts +443 -0
- package/src/subscriptions/routes.ts +146 -0
- package/src/subscriptions/runtime.ts +65 -0
- package/src/subscriptions/schemas.ts +101 -0
- package/src/subscriptions/service.ts +510 -0
- package/src/subscriptions/types.ts +80 -0
- package/src/types.ts +1 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from "astro/zod";
|
|
2
|
+
import { getDb, PluginRouteError, type PluginRoute, type RouteContext } from "dineway";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
PaymentStripeSecretStore,
|
|
6
|
+
PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET,
|
|
7
|
+
} from "../configuration/secrets.js";
|
|
8
|
+
import { getPaymentSubscriptionService, getSubscriptionConfiguration } from "./runtime.js";
|
|
9
|
+
import {
|
|
10
|
+
checkoutSchema,
|
|
11
|
+
portalSchema,
|
|
12
|
+
memberListSchema,
|
|
13
|
+
saveSubscriptionPlanSchema,
|
|
14
|
+
subscriptionIdSchema,
|
|
15
|
+
} from "./schemas.js";
|
|
16
|
+
import type { PaymentSubscriptionService } from "./service.js";
|
|
17
|
+
import { SubscriptionError, type SubscriptionOptions } from "./types.js";
|
|
18
|
+
|
|
19
|
+
const empty = z.object({}).strict();
|
|
20
|
+
const secretSchema = z
|
|
21
|
+
.object({ webhookSecret: z.string().trim().startsWith("whsec_").max(512) })
|
|
22
|
+
.strict();
|
|
23
|
+
type PaymentPermission = "payments:read_own" | "payments:manage_own" | "payments:manage";
|
|
24
|
+
export interface SubscriptionRouteOptions {
|
|
25
|
+
service?: () => Promise<PaymentSubscriptionService>;
|
|
26
|
+
}
|
|
27
|
+
export function createSubscriptionRoutes(
|
|
28
|
+
options: SubscriptionOptions,
|
|
29
|
+
overrides: SubscriptionRouteOptions = {},
|
|
30
|
+
): Record<string, PluginRoute> {
|
|
31
|
+
const service = overrides.service ?? (() => getPaymentSubscriptionService(options));
|
|
32
|
+
const privateRoute = <T>(
|
|
33
|
+
schema: z.ZodType<T>,
|
|
34
|
+
permission: PaymentPermission,
|
|
35
|
+
action: (context: RouteContext<T>) => Promise<unknown>,
|
|
36
|
+
): PluginRoute => ({
|
|
37
|
+
methods: ["POST"],
|
|
38
|
+
input: schema as never,
|
|
39
|
+
authorization: { permission, anyOfTokenScopes: ["admin"] },
|
|
40
|
+
handler: (async (context: RouteContext<T>) =>
|
|
41
|
+
run(async () => {
|
|
42
|
+
const actor = context.actor;
|
|
43
|
+
if (!actor || actor.type !== "user" || actor.authMethod !== "session" || !actor.userId) {
|
|
44
|
+
throw new SubscriptionError("UNAUTHORIZED", "A member session is required", 401);
|
|
45
|
+
}
|
|
46
|
+
if (permission === "payments:manage" && actor.role < 50)
|
|
47
|
+
throw new SubscriptionError("FORBIDDEN", "Administrator access is required", 403);
|
|
48
|
+
if (
|
|
49
|
+
context.request.headers.get("X-Dineway-Request") !== "1" ||
|
|
50
|
+
context.request.headers.get("Origin") !== new URL(options.siteUrl).origin
|
|
51
|
+
) {
|
|
52
|
+
throw new SubscriptionError("CSRF_REJECTED", "Use a same-origin request", 403);
|
|
53
|
+
}
|
|
54
|
+
return action(context);
|
|
55
|
+
})) as never,
|
|
56
|
+
});
|
|
57
|
+
const memberId = (context: RouteContext) => {
|
|
58
|
+
if (!context.actor?.userId)
|
|
59
|
+
throw new SubscriptionError("UNAUTHORIZED", "A member session is required", 401);
|
|
60
|
+
return context.actor.userId;
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
"subscriptions/plans": {
|
|
64
|
+
methods: ["GET"],
|
|
65
|
+
public: true,
|
|
66
|
+
input: empty.default({}),
|
|
67
|
+
handler: async () => run(async () => ({ items: await (await service()).listPlans() })),
|
|
68
|
+
},
|
|
69
|
+
"subscriptions/checkout": privateRoute(checkoutSchema, "payments:manage_own", async (context) =>
|
|
70
|
+
(await service()).createCheckout(memberId(context), context.input),
|
|
71
|
+
),
|
|
72
|
+
"subscriptions/portal": privateRoute(portalSchema, "payments:manage_own", async (context) =>
|
|
73
|
+
(await service()).createPortal(memberId(context), context.input),
|
|
74
|
+
),
|
|
75
|
+
"subscriptions/member": privateRoute(empty, "payments:read_own", async (context) =>
|
|
76
|
+
(await service()).getMemberBilling(memberId(context)),
|
|
77
|
+
),
|
|
78
|
+
"subscriptions/entitlements": privateRoute(empty, "payments:read_own", async (context) => ({
|
|
79
|
+
entitlements: await (await service()).getEntitlements(memberId(context)),
|
|
80
|
+
})),
|
|
81
|
+
"subscriptions/configuration/get": privateRoute(empty, "payments:manage", async () =>
|
|
82
|
+
getSubscriptionConfiguration(options),
|
|
83
|
+
),
|
|
84
|
+
"subscriptions/configuration/update": privateRoute(
|
|
85
|
+
secretSchema,
|
|
86
|
+
"payments:manage",
|
|
87
|
+
async (context) => {
|
|
88
|
+
await new PaymentStripeSecretStore(await getDb()).set(
|
|
89
|
+
PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET,
|
|
90
|
+
context.input.webhookSecret,
|
|
91
|
+
);
|
|
92
|
+
return getSubscriptionConfiguration(options);
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
"subscriptions/plans/list": privateRoute(empty, "payments:manage", async () => ({
|
|
96
|
+
items: await (await service()).listPlans(true),
|
|
97
|
+
})),
|
|
98
|
+
"subscriptions/plans/save": privateRoute(
|
|
99
|
+
saveSubscriptionPlanSchema,
|
|
100
|
+
"payments:manage",
|
|
101
|
+
async (context) => ({ plan: await (await service()).savePlan(context.input) }),
|
|
102
|
+
),
|
|
103
|
+
"subscriptions/list": privateRoute(memberListSchema, "payments:manage", async (context) =>
|
|
104
|
+
(await service()).listSubscriptions(context.input.cursor, context.input.limit),
|
|
105
|
+
),
|
|
106
|
+
"subscriptions/get": privateRoute(subscriptionIdSchema, "payments:manage", async (context) =>
|
|
107
|
+
(await service()).subscriptionDetail(context.input.subscriptionId),
|
|
108
|
+
),
|
|
109
|
+
"subscriptions/sync": privateRoute(subscriptionIdSchema, "payments:manage", async (context) => {
|
|
110
|
+
const client = await service();
|
|
111
|
+
await client.subscriptionDetail(context.input.subscriptionId);
|
|
112
|
+
await client.refreshSubscription(context.input.subscriptionId);
|
|
113
|
+
return client.subscriptionDetail(context.input.subscriptionId);
|
|
114
|
+
}),
|
|
115
|
+
"subscriptions/webhook": {
|
|
116
|
+
methods: ["POST"],
|
|
117
|
+
public: true,
|
|
118
|
+
body: "raw",
|
|
119
|
+
maxBodyBytes: 262144,
|
|
120
|
+
handler: async (context) =>
|
|
121
|
+
run(async () => {
|
|
122
|
+
if (!context.rawBody)
|
|
123
|
+
throw new SubscriptionError("INVALID_SIGNATURE", "A signed body is required");
|
|
124
|
+
await (
|
|
125
|
+
await service()
|
|
126
|
+
).receiveWebhook(context.rawBody, context.request.headers.get("Stripe-Signature"));
|
|
127
|
+
return { received: true };
|
|
128
|
+
}, true),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async function run<T>(action: () => Promise<T>, webhook = false): Promise<T> {
|
|
133
|
+
try {
|
|
134
|
+
return await action();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof SubscriptionError)
|
|
137
|
+
throw new PluginRouteError(error.code, error.message, error.status);
|
|
138
|
+
if (error instanceof z.ZodError)
|
|
139
|
+
throw new PluginRouteError("VALIDATION_ERROR", "Invalid subscription input", 400);
|
|
140
|
+
throw new PluginRouteError(
|
|
141
|
+
webhook ? "WEBHOOK_RETRY_REQUIRED" : "BILLING_UNAVAILABLE",
|
|
142
|
+
webhook ? "Retry this webhook" : "Billing is temporarily unavailable",
|
|
143
|
+
503,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getDb, type PluginSecretStoreOptions } from "dineway";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PaymentStripeSecretStore,
|
|
5
|
+
PAYMENT_STRIPE_SECRET_KEY,
|
|
6
|
+
PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET,
|
|
7
|
+
} from "../configuration/secrets.js";
|
|
8
|
+
import { getPaymentStripeConfiguration } from "../configuration/service.js";
|
|
9
|
+
import { PaymentSubscriptionService } from "./service.js";
|
|
10
|
+
import { SubscriptionError, type SubscriptionOptions } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export async function getPaymentSubscriptionService(
|
|
13
|
+
options: SubscriptionOptions,
|
|
14
|
+
secretOptions?: PluginSecretStoreOptions,
|
|
15
|
+
): Promise<PaymentSubscriptionService> {
|
|
16
|
+
const db = await getDb();
|
|
17
|
+
const config = await getPaymentStripeConfiguration(db, secretOptions);
|
|
18
|
+
const store = new PaymentStripeSecretStore(db, secretOptions);
|
|
19
|
+
const secretKey = await store.resolveForRuntime(PAYMENT_STRIPE_SECRET_KEY);
|
|
20
|
+
const webhookSecret = await store.resolveForRuntime(PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET);
|
|
21
|
+
if (
|
|
22
|
+
config.settings.configurationStatus !== "valid" ||
|
|
23
|
+
!config.settings.stripeAccountId ||
|
|
24
|
+
!secretKey ||
|
|
25
|
+
!webhookSecret
|
|
26
|
+
) {
|
|
27
|
+
throw new SubscriptionError(
|
|
28
|
+
"BILLING_UNAVAILABLE",
|
|
29
|
+
"Configure and verify Stripe and the subscription webhook first",
|
|
30
|
+
503,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return new PaymentSubscriptionService({
|
|
34
|
+
db,
|
|
35
|
+
scope: {
|
|
36
|
+
accountId: config.settings.stripeAccountId,
|
|
37
|
+
mode: config.settings.mode,
|
|
38
|
+
namespace: options.namespace,
|
|
39
|
+
},
|
|
40
|
+
siteUrl: options.siteUrl,
|
|
41
|
+
secretKey,
|
|
42
|
+
webhookSecret,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function getSubscriptionConfiguration(options: SubscriptionOptions) {
|
|
47
|
+
const db = await getDb();
|
|
48
|
+
const configuration = await getPaymentStripeConfiguration(db);
|
|
49
|
+
const webhookSecret = await new PaymentStripeSecretStore(db).status(
|
|
50
|
+
PAYMENT_STRIPE_SUBSCRIPTION_WEBHOOK_SECRET,
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
namespace: options.namespace,
|
|
54
|
+
mode: configuration.settings.mode,
|
|
55
|
+
accountId: configuration.settings.stripeAccountId,
|
|
56
|
+
ready:
|
|
57
|
+
configuration.settings.configurationStatus === "valid" &&
|
|
58
|
+
Boolean(configuration.secretKey && webhookSecret),
|
|
59
|
+
webhookSecret,
|
|
60
|
+
webhookUrl: new URL(
|
|
61
|
+
"/_dineway/api/plugins/dineway-payment/subscriptions/webhook",
|
|
62
|
+
options.siteUrl,
|
|
63
|
+
).href,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "astro/zod";
|
|
2
|
+
const unsafePath = /[\\\r\n]/;
|
|
3
|
+
export const subscriptionLocale = z.enum([
|
|
4
|
+
"auto",
|
|
5
|
+
"bg",
|
|
6
|
+
"cs",
|
|
7
|
+
"da",
|
|
8
|
+
"de",
|
|
9
|
+
"el",
|
|
10
|
+
"en",
|
|
11
|
+
"en-GB",
|
|
12
|
+
"es",
|
|
13
|
+
"es-419",
|
|
14
|
+
"et",
|
|
15
|
+
"fi",
|
|
16
|
+
"fil",
|
|
17
|
+
"fr",
|
|
18
|
+
"fr-CA",
|
|
19
|
+
"hr",
|
|
20
|
+
"hu",
|
|
21
|
+
"id",
|
|
22
|
+
"it",
|
|
23
|
+
"ja",
|
|
24
|
+
"ko",
|
|
25
|
+
"lt",
|
|
26
|
+
"lv",
|
|
27
|
+
"ms",
|
|
28
|
+
"mt",
|
|
29
|
+
"nb",
|
|
30
|
+
"nl",
|
|
31
|
+
"pl",
|
|
32
|
+
"pt",
|
|
33
|
+
"pt-BR",
|
|
34
|
+
"ro",
|
|
35
|
+
"ru",
|
|
36
|
+
"sk",
|
|
37
|
+
"sl",
|
|
38
|
+
"sv",
|
|
39
|
+
"th",
|
|
40
|
+
"tr",
|
|
41
|
+
"vi",
|
|
42
|
+
"zh",
|
|
43
|
+
"zh-HK",
|
|
44
|
+
"zh-TW",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export const subscriptionCode = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/);
|
|
48
|
+
export const entitlementSchema = z
|
|
49
|
+
.record(
|
|
50
|
+
z
|
|
51
|
+
.string()
|
|
52
|
+
.regex(/^[a-z][a-z0-9_.-]{0,95}$/)
|
|
53
|
+
.refine(
|
|
54
|
+
(key) => key !== "constructor" && key !== "prototype",
|
|
55
|
+
"Use a non-reserved entitlement key",
|
|
56
|
+
),
|
|
57
|
+
z.union([z.boolean(), z.number().finite().nonnegative()]),
|
|
58
|
+
)
|
|
59
|
+
.refine((value) => Object.keys(value).length <= 100, "At most 100 entitlements are supported");
|
|
60
|
+
export const saveSubscriptionPlanSchema = z
|
|
61
|
+
.object({
|
|
62
|
+
code: subscriptionCode,
|
|
63
|
+
name: z.string().trim().min(1).max(160),
|
|
64
|
+
enabled: z.boolean(),
|
|
65
|
+
expectedVersion: z.number().int().nonnegative(),
|
|
66
|
+
entitlements: entitlementSchema,
|
|
67
|
+
monthlyPriceId: z
|
|
68
|
+
.string()
|
|
69
|
+
.regex(/^price_[A-Za-z0-9]+$/)
|
|
70
|
+
.nullable(),
|
|
71
|
+
yearlyPriceId: z
|
|
72
|
+
.string()
|
|
73
|
+
.regex(/^price_[A-Za-z0-9]+$/)
|
|
74
|
+
.nullable(),
|
|
75
|
+
})
|
|
76
|
+
.strict();
|
|
77
|
+
export const subscriptionReturnPath = z
|
|
78
|
+
.string()
|
|
79
|
+
.max(1024)
|
|
80
|
+
.refine(
|
|
81
|
+
(value) => value.startsWith("/") && !value.startsWith("//") && !unsafePath.test(value),
|
|
82
|
+
"Use a relative return path",
|
|
83
|
+
);
|
|
84
|
+
export const checkoutSchema = z
|
|
85
|
+
.object({
|
|
86
|
+
planCode: subscriptionCode,
|
|
87
|
+
interval: z.enum(["month", "year"]),
|
|
88
|
+
locale: subscriptionLocale.optional(),
|
|
89
|
+
returnPath: subscriptionReturnPath,
|
|
90
|
+
})
|
|
91
|
+
.strict();
|
|
92
|
+
export const portalSchema = checkoutSchema.pick({ locale: true, returnPath: true });
|
|
93
|
+
export const memberListSchema = z
|
|
94
|
+
.object({
|
|
95
|
+
cursor: z.string().max(255).optional(),
|
|
96
|
+
limit: z.number().int().min(1).max(100).default(50),
|
|
97
|
+
})
|
|
98
|
+
.strict();
|
|
99
|
+
export const subscriptionIdSchema = z
|
|
100
|
+
.object({ subscriptionId: z.string().regex(/^sub_[A-Za-z0-9]+$/) })
|
|
101
|
+
.strict();
|