@ysk-kit/cli 1.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/dist/add-capability.d.ts +4 -0
- package/dist/add-capability.d.ts.map +1 -0
- package/dist/add-capability.js +104 -0
- package/dist/add-capability.js.map +1 -0
- package/dist/add-module.d.ts +5 -0
- package/dist/add-module.d.ts.map +1 -0
- package/dist/add-module.js +210 -0
- package/dist/add-module.js.map +1 -0
- package/dist/agent-stubs.d.ts +3 -0
- package/dist/agent-stubs.d.ts.map +1 -0
- package/dist/agent-stubs.js +25 -0
- package/dist/agent-stubs.js.map +1 -0
- package/dist/apply-capability.d.ts +3 -0
- package/dist/apply-capability.d.ts.map +1 -0
- package/dist/apply-capability.js +189 -0
- package/dist/apply-capability.js.map +1 -0
- package/dist/capability-catalog.d.ts +25 -0
- package/dist/capability-catalog.d.ts.map +1 -0
- package/dist/capability-catalog.js +123 -0
- package/dist/capability-catalog.js.map +1 -0
- package/dist/capability-patches.d.ts +40 -0
- package/dist/capability-patches.d.ts.map +1 -0
- package/dist/capability-patches.js +616 -0
- package/dist/capability-patches.js.map +1 -0
- package/dist/check-agent.d.ts +9 -0
- package/dist/check-agent.d.ts.map +1 -0
- package/dist/check-agent.js +85 -0
- package/dist/check-agent.js.map +1 -0
- package/dist/generate-openapi.d.ts +2 -0
- package/dist/generate-openapi.d.ts.map +1 -0
- package/dist/generate-openapi.js +52 -0
- package/dist/generate-openapi.js.map +1 -0
- package/dist/help.d.ts +2 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +54 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/merge-prisma.d.ts +15 -0
- package/dist/merge-prisma.d.ts.map +1 -0
- package/dist/merge-prisma.js +90 -0
- package/dist/merge-prisma.js.map +1 -0
- package/dist/patch-text.d.ts +17 -0
- package/dist/patch-text.d.ts.map +1 -0
- package/dist/patch-text.js +72 -0
- package/dist/patch-text.js.map +1 -0
- package/dist/upgrade.d.ts +16 -0
- package/dist/upgrade.d.ts.map +1 -0
- package/dist/upgrade.js +110 -0
- package/dist/upgrade.js.map +1 -0
- package/package.json +41 -0
- package/templates/agent/skills/add-capability/SKILL.md +8 -0
- package/templates/agent/skills/add-module/SKILL.md +8 -0
- package/templates/agent/skills/envelope-api/SKILL.md +8 -0
- package/templates/agent/skills/fix-layers/SKILL.md +8 -0
- package/templates/agent/skills/new-product/SKILL.md +8 -0
- package/templates/agent/skills/verify-change/SKILL.md +8 -0
- package/templates/agent/ysk-kit.mdc +6 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/application/billing-service.ts +140 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/domain/billing-port.ts +23 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/domain/subscription-repository.ts +22 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing-router.ts +85 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing.app.test.ts +273 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/log-billing.ts +31 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/memory-subscription-repository.ts +27 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/prisma-subscription-repository.ts +52 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.test.ts +127 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.ts +160 -0
- package/templates/capabilities/billing/apps/web/src/features/orgs/billing-page.tsx +183 -0
- package/templates/capabilities/billing/packages/contracts/src/api/billing.ts +85 -0
- package/templates/capabilities/billing/packages/contracts/src/dto/billing.ts +59 -0
- package/templates/capabilities/billing/packages/sdk/src/resources/billing.ts +38 -0
- package/templates/capabilities/billing/packages/web-sdk/src/billing-hooks.ts +56 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/application/llm-service.ts +48 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/domain/usage-repository.ts +10 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm-router.ts +88 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm.app.test.ts +89 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/memory-usage-repository.ts +13 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/prisma-usage-repository.ts +17 -0
- package/templates/capabilities/llm/apps/web/src/features/llm/llm-page.tsx +53 -0
- package/templates/capabilities/llm/packages/contracts/src/api/llm.ts +26 -0
- package/templates/capabilities/llm/packages/contracts/src/dto/llm.ts +33 -0
- package/templates/capabilities/llm/packages/sdk/src/resources/llm.ts +68 -0
- package/templates/capabilities/llm/packages/web-sdk/src/llm-hooks.ts +12 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/application/device-service.ts +42 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/domain/device-repository.ts +22 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/infra/device-router.ts +42 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/infra/device.app.test.ts +34 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/infra/memory-device-repository.ts +33 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/infra/prisma-device-repository.ts +42 -0
- package/templates/capabilities/push/apps/mobile/src/adapters/push.ts +21 -0
- package/templates/capabilities/push/packages/contracts/src/api/devices.ts +33 -0
- package/templates/capabilities/push/packages/contracts/src/dto/device.ts +18 -0
- package/templates/capabilities/push/packages/sdk/src/resources/devices.ts +10 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/application/organization-service.ts +272 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/domain/organization-repository.ts +58 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/infra/memory-organization-repository.ts +141 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization-router.ts +130 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization.app.test.ts +213 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/infra/prisma-organization-repository.ts +142 -0
- package/templates/capabilities/team/apps/mobile/src/screens/invite-screen.tsx +40 -0
- package/templates/capabilities/team/apps/mobile/src/screens/org-detail-screen.tsx +154 -0
- package/templates/capabilities/team/apps/mobile/src/screens/orgs-screen.tsx +53 -0
- package/templates/capabilities/team/apps/web/src/features/orgs/invite-page.tsx +77 -0
- package/templates/capabilities/team/apps/web/src/features/orgs/org-detail-page.tsx +138 -0
- package/templates/capabilities/team/apps/web/src/features/orgs/orgs-page.tsx +73 -0
- package/templates/capabilities/team/packages/contracts/src/api/organizations.ts +154 -0
- package/templates/capabilities/team/packages/contracts/src/dto/organization.ts +58 -0
- package/templates/capabilities/team/packages/sdk/src/resources/organizations.ts +53 -0
- package/templates/capabilities/team/packages/web-sdk/src/organizations-hooks.ts +80 -0
- package/templates/module/api.ts.tmpl +32 -0
- package/templates/module/application-service.ts.tmpl +9 -0
- package/templates/module/domain-repository.ts.tmpl +15 -0
- package/templates/module/dto.ts.tmpl +21 -0
- package/templates/module/memory-repository.ts.tmpl +36 -0
- package/templates/module/model.prisma.tmpl +8 -0
- package/templates/module/prisma-repository.ts.tmpl +38 -0
- package/templates/module/router.ts.tmpl +32 -0
- package/templates/module/sdk-resource.ts.tmpl +17 -0
- package/templates/module/service.test.ts.tmpl +33 -0
- package/templates/module/web-hooks.ts.tmpl +24 -0
- package/templates/module/web-page.tsx.tmpl +69 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { BILLING_PLANS } from '@ysk-kit/contracts';
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Input,
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from '@ysk-kit/ui';
|
|
12
|
+
import { formatHkd, orgRoleCan } from '@ysk-kit/ui-logic';
|
|
13
|
+
import { createBillingHooks, createOrganizationHooks } from '@ysk-kit/web-sdk';
|
|
14
|
+
import { type FormEvent, useState } from 'react';
|
|
15
|
+
import { api, userHooks } from '../../lib/client';
|
|
16
|
+
|
|
17
|
+
const orgHooks = createOrganizationHooks(api);
|
|
18
|
+
const billingHooks = createBillingHooks(api);
|
|
19
|
+
|
|
20
|
+
export function BillingPage({ organizationId }: { organizationId: string }) {
|
|
21
|
+
const me = userHooks.useMe();
|
|
22
|
+
const members = orgHooks.useOrganizationMembers(organizationId);
|
|
23
|
+
const myRole = members.data?.find((row) => row.userId === me.data?.id)?.role;
|
|
24
|
+
const canBill = myRole ? orgRoleCan(myRole, 'org.billing') : false;
|
|
25
|
+
const subscription = billingHooks.useSubscription(organizationId, canBill);
|
|
26
|
+
const invoices = billingHooks.useInvoices(organizationId, canBill);
|
|
27
|
+
const checkout = billingHooks.useCheckout();
|
|
28
|
+
const portal = billingHooks.useBillingPortal();
|
|
29
|
+
const cancel = billingHooks.useCancelSubscription();
|
|
30
|
+
const invoicePdf = billingHooks.useInvoicePdf();
|
|
31
|
+
const [seatCount, setSeatCount] = useState(1);
|
|
32
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
const onCheckout = (event: FormEvent) => {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
const pageUrl = `${window.location.origin}/orgs/${organizationId}/billing`;
|
|
37
|
+
setFormError(null);
|
|
38
|
+
checkout.mutate(
|
|
39
|
+
{
|
|
40
|
+
organizationId,
|
|
41
|
+
planCode: 'pro',
|
|
42
|
+
successUrl: pageUrl,
|
|
43
|
+
cancelUrl: pageUrl,
|
|
44
|
+
seatCount,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
onSuccess: (data) => {
|
|
48
|
+
window.location.assign(data.url);
|
|
49
|
+
},
|
|
50
|
+
onError: (error) => setFormError(error.message),
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (members.isPending) {
|
|
56
|
+
return (
|
|
57
|
+
<section className="space-y-2">
|
|
58
|
+
<h1 className="text-2xl font-semibold">Billing</h1>
|
|
59
|
+
<p className="text-zinc-600">Loading…</p>
|
|
60
|
+
</section>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!canBill) {
|
|
65
|
+
return (
|
|
66
|
+
<section className="space-y-2">
|
|
67
|
+
<h1 className="text-2xl font-semibold">Billing</h1>
|
|
68
|
+
<p className="text-zinc-600">Billing is for organization owners and admins.</p>
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<section className="space-y-6">
|
|
75
|
+
<h1 className="text-2xl font-semibold">Billing</h1>
|
|
76
|
+
{formError ? <p className="text-sm text-red-600">{formError}</p> : null}
|
|
77
|
+
<p className="text-sm text-zinc-600">
|
|
78
|
+
Plan {subscription.data?.planCode ?? 'free'} · {subscription.data?.seatCount ?? 1} seats
|
|
79
|
+
{subscription.data?.currentPeriodEnd
|
|
80
|
+
? ` · period ends ${subscription.data.currentPeriodEnd}`
|
|
81
|
+
: ''}
|
|
82
|
+
</p>
|
|
83
|
+
<ul className="text-sm text-zinc-600">
|
|
84
|
+
{BILLING_PLANS.map((plan) => (
|
|
85
|
+
<li key={plan.code}>
|
|
86
|
+
{plan.name} · {formatHkd(plan.amountHkdCents / 100)} / {plan.interval}
|
|
87
|
+
</li>
|
|
88
|
+
))}
|
|
89
|
+
</ul>
|
|
90
|
+
<form onSubmit={onCheckout} className="flex flex-wrap items-end gap-3">
|
|
91
|
+
<label className="grid gap-1 text-sm" htmlFor="seat-count">
|
|
92
|
+
Seats
|
|
93
|
+
<Input
|
|
94
|
+
id="seat-count"
|
|
95
|
+
type="number"
|
|
96
|
+
min={1}
|
|
97
|
+
max={500}
|
|
98
|
+
value={seatCount}
|
|
99
|
+
onChange={(e) => setSeatCount(Number(e.target.value))}
|
|
100
|
+
required
|
|
101
|
+
/>
|
|
102
|
+
</label>
|
|
103
|
+
<Button type="submit" disabled={checkout.isPending}>
|
|
104
|
+
Checkout Pro
|
|
105
|
+
</Button>
|
|
106
|
+
</form>
|
|
107
|
+
<div className="flex flex-wrap gap-2">
|
|
108
|
+
<Button
|
|
109
|
+
type="button"
|
|
110
|
+
variant="outline"
|
|
111
|
+
disabled={portal.isPending}
|
|
112
|
+
onClick={() =>
|
|
113
|
+
portal.mutate(
|
|
114
|
+
{
|
|
115
|
+
organizationId,
|
|
116
|
+
returnUrl: `${window.location.origin}/orgs/${organizationId}/billing`,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
onSuccess: (data) => {
|
|
120
|
+
window.location.assign(data.url);
|
|
121
|
+
},
|
|
122
|
+
onError: (error) => setFormError(error.message),
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
>
|
|
127
|
+
Customer portal
|
|
128
|
+
</Button>
|
|
129
|
+
<Button
|
|
130
|
+
type="button"
|
|
131
|
+
variant="outline"
|
|
132
|
+
disabled={cancel.isPending}
|
|
133
|
+
onClick={() => {
|
|
134
|
+
if (!window.confirm('Cancel this subscription?')) return;
|
|
135
|
+
cancel.mutate({ organizationId }, { onError: (error) => setFormError(error.message) });
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
Cancel
|
|
139
|
+
</Button>
|
|
140
|
+
</div>
|
|
141
|
+
<Table>
|
|
142
|
+
<TableHeader>
|
|
143
|
+
<TableRow>
|
|
144
|
+
<TableHead>Invoice</TableHead>
|
|
145
|
+
<TableHead>Status</TableHead>
|
|
146
|
+
<TableHead>Amount</TableHead>
|
|
147
|
+
<TableHead />
|
|
148
|
+
</TableRow>
|
|
149
|
+
</TableHeader>
|
|
150
|
+
<TableBody>
|
|
151
|
+
{(invoices.data ?? []).map((invoice) => (
|
|
152
|
+
<TableRow key={invoice.id}>
|
|
153
|
+
<TableCell>{invoice.number ?? invoice.id}</TableCell>
|
|
154
|
+
<TableCell>{invoice.status}</TableCell>
|
|
155
|
+
<TableCell>{formatHkd(invoice.amountDue / 100)}</TableCell>
|
|
156
|
+
<TableCell>
|
|
157
|
+
<Button
|
|
158
|
+
type="button"
|
|
159
|
+
variant="outline"
|
|
160
|
+
size="sm"
|
|
161
|
+
disabled={invoicePdf.isPending}
|
|
162
|
+
onClick={() =>
|
|
163
|
+
invoicePdf.mutate(
|
|
164
|
+
{ id: invoice.id, organizationId },
|
|
165
|
+
{
|
|
166
|
+
onSuccess: (url) => {
|
|
167
|
+
window.location.assign(url);
|
|
168
|
+
},
|
|
169
|
+
onError: (error) => setFormError(error.message),
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
>
|
|
174
|
+
PDF
|
|
175
|
+
</Button>
|
|
176
|
+
</TableCell>
|
|
177
|
+
</TableRow>
|
|
178
|
+
))}
|
|
179
|
+
</TableBody>
|
|
180
|
+
</Table>
|
|
181
|
+
</section>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { initContract } from '@ts-rest/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
BillingPlanDtoSchema,
|
|
5
|
+
CancelCommandSchema,
|
|
6
|
+
CheckoutCommandSchema,
|
|
7
|
+
CheckoutDtoSchema,
|
|
8
|
+
InvoiceDtoSchema,
|
|
9
|
+
OrganizationIdQuerySchema,
|
|
10
|
+
PortalCommandSchema,
|
|
11
|
+
SubscriptionDtoSchema,
|
|
12
|
+
} from '../dto/billing';
|
|
13
|
+
import { ErrSchema, OkSchema } from '../errors/envelope';
|
|
14
|
+
|
|
15
|
+
const c = initContract();
|
|
16
|
+
|
|
17
|
+
export const billingContract = c.router({
|
|
18
|
+
plans: {
|
|
19
|
+
method: 'GET',
|
|
20
|
+
path: '/v1/billing/plans',
|
|
21
|
+
responses: { 200: OkSchema(z.array(BillingPlanDtoSchema)) },
|
|
22
|
+
summary: 'List billing plans',
|
|
23
|
+
},
|
|
24
|
+
subscription: {
|
|
25
|
+
method: 'GET',
|
|
26
|
+
path: '/v1/billing/subscription',
|
|
27
|
+
query: OrganizationIdQuerySchema,
|
|
28
|
+
responses: {
|
|
29
|
+
200: OkSchema(SubscriptionDtoSchema),
|
|
30
|
+
401: ErrSchema,
|
|
31
|
+
403: ErrSchema,
|
|
32
|
+
422: ErrSchema,
|
|
33
|
+
},
|
|
34
|
+
summary: 'Current organization subscription',
|
|
35
|
+
},
|
|
36
|
+
checkout: {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
path: '/v1/billing/checkout',
|
|
39
|
+
body: CheckoutCommandSchema,
|
|
40
|
+
responses: {
|
|
41
|
+
200: OkSchema(CheckoutDtoSchema),
|
|
42
|
+
401: ErrSchema,
|
|
43
|
+
403: ErrSchema,
|
|
44
|
+
422: ErrSchema,
|
|
45
|
+
},
|
|
46
|
+
summary: 'Start organization checkout',
|
|
47
|
+
},
|
|
48
|
+
cancel: {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
path: '/v1/billing/cancel',
|
|
51
|
+
body: CancelCommandSchema,
|
|
52
|
+
responses: {
|
|
53
|
+
200: OkSchema(z.object({ canceled: z.literal(true) })),
|
|
54
|
+
401: ErrSchema,
|
|
55
|
+
403: ErrSchema,
|
|
56
|
+
422: ErrSchema,
|
|
57
|
+
},
|
|
58
|
+
summary: 'Cancel organization subscription',
|
|
59
|
+
},
|
|
60
|
+
invoices: {
|
|
61
|
+
method: 'GET',
|
|
62
|
+
path: '/v1/billing/invoices',
|
|
63
|
+
query: OrganizationIdQuerySchema,
|
|
64
|
+
responses: {
|
|
65
|
+
200: OkSchema(z.array(InvoiceDtoSchema)),
|
|
66
|
+
401: ErrSchema,
|
|
67
|
+
403: ErrSchema,
|
|
68
|
+
422: ErrSchema,
|
|
69
|
+
},
|
|
70
|
+
summary: 'List organization invoices',
|
|
71
|
+
},
|
|
72
|
+
portal: {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
path: '/v1/billing/portal',
|
|
75
|
+
body: PortalCommandSchema,
|
|
76
|
+
responses: {
|
|
77
|
+
200: OkSchema(CheckoutDtoSchema),
|
|
78
|
+
401: ErrSchema,
|
|
79
|
+
403: ErrSchema,
|
|
80
|
+
409: ErrSchema,
|
|
81
|
+
422: ErrSchema,
|
|
82
|
+
},
|
|
83
|
+
summary: 'Open Stripe Customer Portal',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BillingPlanCodeSchema, SubscriptionStatusSchema } from '../enums/billing-plan';
|
|
3
|
+
|
|
4
|
+
export const BillingPlanDtoSchema = z.object({
|
|
5
|
+
code: BillingPlanCodeSchema,
|
|
6
|
+
name: z.string().min(1),
|
|
7
|
+
amountHkdCents: z.number().int().nonnegative(),
|
|
8
|
+
interval: z.literal('month'),
|
|
9
|
+
});
|
|
10
|
+
export type BillingPlanDto = z.infer<typeof BillingPlanDtoSchema>;
|
|
11
|
+
|
|
12
|
+
export const SubscriptionDtoSchema = z.object({
|
|
13
|
+
planCode: BillingPlanCodeSchema,
|
|
14
|
+
status: SubscriptionStatusSchema,
|
|
15
|
+
currentPeriodEnd: z.iso.datetime().nullable(),
|
|
16
|
+
seatCount: z.number().int().positive(),
|
|
17
|
+
});
|
|
18
|
+
export type SubscriptionDto = z.infer<typeof SubscriptionDtoSchema>;
|
|
19
|
+
|
|
20
|
+
export const OrganizationIdQuerySchema = z.object({
|
|
21
|
+
organizationId: z.string().uuid(),
|
|
22
|
+
});
|
|
23
|
+
export type OrganizationIdQuery = z.infer<typeof OrganizationIdQuerySchema>;
|
|
24
|
+
|
|
25
|
+
export const CheckoutCommandSchema = z.object({
|
|
26
|
+
organizationId: z.string().uuid(),
|
|
27
|
+
planCode: BillingPlanCodeSchema,
|
|
28
|
+
successUrl: z.string().url(),
|
|
29
|
+
cancelUrl: z.string().url(),
|
|
30
|
+
seatCount: z.number().int().min(1).max(500).default(1),
|
|
31
|
+
});
|
|
32
|
+
export type CheckoutCommand = z.infer<typeof CheckoutCommandSchema>;
|
|
33
|
+
|
|
34
|
+
export const CheckoutDtoSchema = z.object({
|
|
35
|
+
url: z.string().url(),
|
|
36
|
+
});
|
|
37
|
+
export type CheckoutDto = z.infer<typeof CheckoutDtoSchema>;
|
|
38
|
+
|
|
39
|
+
export const PortalCommandSchema = z.object({
|
|
40
|
+
organizationId: z.string().uuid(),
|
|
41
|
+
returnUrl: z.string().url(),
|
|
42
|
+
});
|
|
43
|
+
export type PortalCommand = z.infer<typeof PortalCommandSchema>;
|
|
44
|
+
|
|
45
|
+
export const CancelCommandSchema = z.object({
|
|
46
|
+
organizationId: z.string().uuid(),
|
|
47
|
+
});
|
|
48
|
+
export type CancelCommand = z.infer<typeof CancelCommandSchema>;
|
|
49
|
+
|
|
50
|
+
export const InvoiceDtoSchema = z.object({
|
|
51
|
+
id: z.string().min(1),
|
|
52
|
+
number: z.string().nullable(),
|
|
53
|
+
status: z.string().min(1),
|
|
54
|
+
amountDue: z.number().int(),
|
|
55
|
+
currency: z.string().min(1),
|
|
56
|
+
hostedInvoiceUrl: z.string().url().nullable(),
|
|
57
|
+
createdAt: z.iso.datetime(),
|
|
58
|
+
});
|
|
59
|
+
export type InvoiceDto = z.infer<typeof InvoiceDtoSchema>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BillingPlanDto,
|
|
3
|
+
CancelCommand,
|
|
4
|
+
CheckoutCommand,
|
|
5
|
+
CheckoutDto,
|
|
6
|
+
InvoiceDto,
|
|
7
|
+
PortalCommand,
|
|
8
|
+
SubscriptionDto,
|
|
9
|
+
} from '@ysk-kit/contracts';
|
|
10
|
+
import type { HttpClient } from '../http';
|
|
11
|
+
|
|
12
|
+
const withOrg = (path: string, organizationId: string): string =>
|
|
13
|
+
`${path}?organizationId=${encodeURIComponent(organizationId)}`;
|
|
14
|
+
|
|
15
|
+
export const billingResource = (http: HttpClient) => ({
|
|
16
|
+
plans: () => http.request<BillingPlanDto[]>('/v1/billing/plans'),
|
|
17
|
+
subscription: (organizationId: string) =>
|
|
18
|
+
http.request<SubscriptionDto>(withOrg('/v1/billing/subscription', organizationId)),
|
|
19
|
+
invoices: (organizationId: string) =>
|
|
20
|
+
http.request<InvoiceDto[]>(withOrg('/v1/billing/invoices', organizationId)),
|
|
21
|
+
invoicePdfUrl: (id: string, organizationId: string) =>
|
|
22
|
+
http.requestLocation(withOrg(`/v1/billing/invoices/${id}/pdf`, organizationId)),
|
|
23
|
+
checkout: (body: CheckoutCommand) =>
|
|
24
|
+
http.request<CheckoutDto>('/v1/billing/checkout', {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
}),
|
|
28
|
+
cancel: (body: CancelCommand) =>
|
|
29
|
+
http.request<{ canceled: true }>('/v1/billing/cancel', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
}),
|
|
33
|
+
portal: (body: PortalCommand) =>
|
|
34
|
+
http.request<CheckoutDto>('/v1/billing/portal', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import type { CancelCommand, CheckoutCommand, PortalCommand } from '@ysk-kit/contracts';
|
|
3
|
+
import type { YskClient } from '@ysk-kit/sdk';
|
|
4
|
+
|
|
5
|
+
export const billingQueryKey = (organizationId: string) => ['billing', organizationId] as const;
|
|
6
|
+
|
|
7
|
+
export function createBillingHooks(client: YskClient) {
|
|
8
|
+
return {
|
|
9
|
+
useBillingPlans: () =>
|
|
10
|
+
useQuery({
|
|
11
|
+
queryKey: ['billing-plans'],
|
|
12
|
+
queryFn: () => client.billing.plans(),
|
|
13
|
+
}),
|
|
14
|
+
useSubscription: (organizationId: string, enabled = true) =>
|
|
15
|
+
useQuery({
|
|
16
|
+
queryKey: [...billingQueryKey(organizationId), 'subscription'],
|
|
17
|
+
queryFn: () => client.billing.subscription(organizationId),
|
|
18
|
+
enabled: organizationId.length > 0 && enabled,
|
|
19
|
+
retry: false,
|
|
20
|
+
}),
|
|
21
|
+
useInvoices: (organizationId: string, enabled = true) =>
|
|
22
|
+
useQuery({
|
|
23
|
+
queryKey: [...billingQueryKey(organizationId), 'invoices'],
|
|
24
|
+
queryFn: () => client.billing.invoices(organizationId),
|
|
25
|
+
enabled: organizationId.length > 0 && enabled,
|
|
26
|
+
retry: false,
|
|
27
|
+
}),
|
|
28
|
+
useCheckout: () => {
|
|
29
|
+
const qc = useQueryClient();
|
|
30
|
+
return useMutation({
|
|
31
|
+
mutationFn: (body: CheckoutCommand) => client.billing.checkout(body),
|
|
32
|
+
onSuccess: (_data, body) => {
|
|
33
|
+
void qc.invalidateQueries({ queryKey: billingQueryKey(body.organizationId) });
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
useBillingPortal: () =>
|
|
38
|
+
useMutation({
|
|
39
|
+
mutationFn: (body: PortalCommand) => client.billing.portal(body),
|
|
40
|
+
}),
|
|
41
|
+
useCancelSubscription: () => {
|
|
42
|
+
const qc = useQueryClient();
|
|
43
|
+
return useMutation({
|
|
44
|
+
mutationFn: (body: CancelCommand) => client.billing.cancel(body),
|
|
45
|
+
onSuccess: (_data, body) => {
|
|
46
|
+
void qc.invalidateQueries({ queryKey: billingQueryKey(body.organizationId) });
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
useInvoicePdf: () =>
|
|
51
|
+
useMutation({
|
|
52
|
+
mutationFn: (args: { id: string; organizationId: string }) =>
|
|
53
|
+
client.billing.invoicePdfUrl(args.id, args.organizationId),
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { LlmCompleteCommand, LlmCompleteDto } from '@ysk-kit/contracts';
|
|
2
|
+
import { AppError } from '@ysk-kit/domain-kernel';
|
|
3
|
+
import type { ILlmPort } from '@ysk-kit/llm';
|
|
4
|
+
import type { ILlmUsageRepository } from '../domain/usage-repository';
|
|
5
|
+
|
|
6
|
+
export const createLlmService = (opts: {
|
|
7
|
+
llm: ILlmPort;
|
|
8
|
+
usage: ILlmUsageRepository;
|
|
9
|
+
configured: boolean;
|
|
10
|
+
production: boolean;
|
|
11
|
+
}) => ({
|
|
12
|
+
models: () => ({ items: [{ id: opts.llm.model }] }),
|
|
13
|
+
complete: async (
|
|
14
|
+
userId: string,
|
|
15
|
+
body: LlmCompleteCommand,
|
|
16
|
+
requestId?: string,
|
|
17
|
+
): Promise<LlmCompleteDto> => {
|
|
18
|
+
if (opts.production && !opts.configured) {
|
|
19
|
+
throw new AppError('INTERNAL', 'LLM not configured', 503);
|
|
20
|
+
}
|
|
21
|
+
const result = await opts.llm.complete(body);
|
|
22
|
+
await opts.usage.create({
|
|
23
|
+
userId,
|
|
24
|
+
model: result.model,
|
|
25
|
+
usage: result.usage,
|
|
26
|
+
requestId,
|
|
27
|
+
});
|
|
28
|
+
return result;
|
|
29
|
+
},
|
|
30
|
+
stream: async function* (userId: string, body: LlmCompleteCommand, requestId?: string) {
|
|
31
|
+
if (opts.production && !opts.configured) {
|
|
32
|
+
throw new AppError('INTERNAL', 'LLM not configured', 503);
|
|
33
|
+
}
|
|
34
|
+
for await (const chunk of opts.llm.stream(body)) {
|
|
35
|
+
if (chunk.done && chunk.usage) {
|
|
36
|
+
await opts.usage.create({
|
|
37
|
+
userId,
|
|
38
|
+
model: opts.llm.model,
|
|
39
|
+
usage: chunk.usage,
|
|
40
|
+
requestId,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
yield chunk;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export type LlmService = ReturnType<typeof createLlmService>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type HttpHandler,
|
|
3
|
+
mountContract,
|
|
4
|
+
requireAuth,
|
|
5
|
+
requirePermission,
|
|
6
|
+
} from '@ysk-kit/api-express';
|
|
7
|
+
import { appContract, type LlmCompleteCommand, LlmCompleteCommandSchema } from '@ysk-kit/contracts';
|
|
8
|
+
import { AppError } from '@ysk-kit/domain-kernel';
|
|
9
|
+
import type { Express } from 'express';
|
|
10
|
+
import type { LlmService } from '../application/llm-service';
|
|
11
|
+
|
|
12
|
+
export const llmHandlers = (service: LlmService): Record<string, HttpHandler> => ({
|
|
13
|
+
complete: {
|
|
14
|
+
auth: 'required',
|
|
15
|
+
permission: 'llm.use',
|
|
16
|
+
handle: async ({ body, auth, requestId }) => {
|
|
17
|
+
if (!auth) throw new AppError('UNAUTHENTICATED');
|
|
18
|
+
return {
|
|
19
|
+
status: 200,
|
|
20
|
+
body: {
|
|
21
|
+
ok: true,
|
|
22
|
+
data: await service.complete(auth.sub, body as LlmCompleteCommand, requestId),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
models: {
|
|
28
|
+
auth: 'required',
|
|
29
|
+
permission: 'llm.use',
|
|
30
|
+
handle: async () => ({
|
|
31
|
+
status: 200,
|
|
32
|
+
body: { ok: true, data: service.models() },
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const writeLlmSse = async (
|
|
38
|
+
service: LlmService,
|
|
39
|
+
userId: string,
|
|
40
|
+
command: LlmCompleteCommand,
|
|
41
|
+
requestId: string,
|
|
42
|
+
write: (chunk: string) => void,
|
|
43
|
+
end: () => void,
|
|
44
|
+
): Promise<void> => {
|
|
45
|
+
for await (const chunk of service.stream(userId, command, requestId)) {
|
|
46
|
+
const event = chunk.done ? 'done' : 'delta';
|
|
47
|
+
write(`event: ${event}\ndata: ${JSON.stringify(chunk)}\n\n`);
|
|
48
|
+
}
|
|
49
|
+
end();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const registerLlmRoutes = (app: Express, service: LlmService): void => {
|
|
53
|
+
mountContract(app, appContract.llm, llmHandlers(service));
|
|
54
|
+
|
|
55
|
+
app.post('/v1/llm/stream', requireAuth, requirePermission('llm.use'), async (req, res, next) => {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = LlmCompleteCommandSchema.parse(req.body);
|
|
58
|
+
const userId = req.auth?.sub;
|
|
59
|
+
if (!userId) {
|
|
60
|
+
res.status(401).json({
|
|
61
|
+
ok: false,
|
|
62
|
+
error: { code: 'UNAUTHENTICATED', message: '尚未登入' },
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
res.status(200);
|
|
67
|
+
res.setHeader('content-type', 'text/event-stream');
|
|
68
|
+
res.setHeader('cache-control', 'no-cache, no-transform');
|
|
69
|
+
res.setHeader('connection', 'keep-alive');
|
|
70
|
+
res.setHeader('x-accel-buffering', 'no');
|
|
71
|
+
res.flushHeaders?.();
|
|
72
|
+
await writeLlmSse(
|
|
73
|
+
service,
|
|
74
|
+
userId,
|
|
75
|
+
parsed,
|
|
76
|
+
String(req.headers['x-request-id'] ?? ''),
|
|
77
|
+
(chunk) => {
|
|
78
|
+
res.write(chunk);
|
|
79
|
+
},
|
|
80
|
+
() => {
|
|
81
|
+
res.end();
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
next(error);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createApp } from '../../../app';
|
|
4
|
+
import { createMemoryInput } from '../../../create-memory-input';
|
|
5
|
+
|
|
6
|
+
describe('llm http', () => {
|
|
7
|
+
it('completes llm as a signed-in user and records usage', async () => {
|
|
8
|
+
const mem = createMemoryInput();
|
|
9
|
+
const app = createApp(mem.input);
|
|
10
|
+
const registered = await request(app).post('/v1/auth/register').send({
|
|
11
|
+
email: 'dev@ysk.hk',
|
|
12
|
+
password: 'password1',
|
|
13
|
+
displayName: 'Ki',
|
|
14
|
+
});
|
|
15
|
+
const token = registered.body.data.accessToken as string;
|
|
16
|
+
const res = await request(app)
|
|
17
|
+
.post('/v1/llm/complete')
|
|
18
|
+
.set('authorization', `Bearer ${token}`)
|
|
19
|
+
.send({ messages: [{ role: 'user', content: 'ping' }] });
|
|
20
|
+
expect(res.status).toBe(200);
|
|
21
|
+
expect(res.body.data.text).toBe('pong');
|
|
22
|
+
expect(mem.usage.rows).toHaveLength(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('rejects llm without a token', async () => {
|
|
26
|
+
const app = createApp(createMemoryInput().input);
|
|
27
|
+
const res = await request(app)
|
|
28
|
+
.post('/v1/llm/complete')
|
|
29
|
+
.send({ messages: [{ role: 'user', content: 'ping' }] });
|
|
30
|
+
expect(res.status).toBe(401);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('streams llm deltas then done', async () => {
|
|
34
|
+
const app = createApp(createMemoryInput().input);
|
|
35
|
+
const registered = await request(app).post('/v1/auth/register').send({
|
|
36
|
+
email: 'dev@ysk.hk',
|
|
37
|
+
password: 'password1',
|
|
38
|
+
displayName: 'Ki',
|
|
39
|
+
});
|
|
40
|
+
const token = registered.body.data.accessToken as string;
|
|
41
|
+
const res = await request(app)
|
|
42
|
+
.post('/v1/llm/stream')
|
|
43
|
+
.set('authorization', `Bearer ${token}`)
|
|
44
|
+
.send({ messages: [{ role: 'user', content: 'ping' }] });
|
|
45
|
+
expect(res.status).toBe(200);
|
|
46
|
+
expect(res.text).toContain('event: delta');
|
|
47
|
+
expect(res.text).toContain('event: done');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('allows an API key with llm.use to complete', async () => {
|
|
51
|
+
const app = createApp(createMemoryInput().input);
|
|
52
|
+
const registered = await request(app).post('/v1/auth/register').send({
|
|
53
|
+
email: 'keys@ysk.hk',
|
|
54
|
+
password: 'password1',
|
|
55
|
+
displayName: 'Keys',
|
|
56
|
+
});
|
|
57
|
+
const jwt = registered.body.data.accessToken as string;
|
|
58
|
+
const created = await request(app)
|
|
59
|
+
.post('/v1/me/api-keys')
|
|
60
|
+
.set('authorization', `Bearer ${jwt}`)
|
|
61
|
+
.send({ name: 'ci', permissions: ['llm.use'] });
|
|
62
|
+
const token = created.body.data.token as string;
|
|
63
|
+
const llm = await request(app)
|
|
64
|
+
.post('/v1/llm/complete')
|
|
65
|
+
.set('authorization', `Bearer ${token}`)
|
|
66
|
+
.send({ messages: [{ role: 'user', content: 'ping' }] });
|
|
67
|
+
expect(llm.status).toBe(200);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('forbids API keys without llm.use from completing', async () => {
|
|
71
|
+
const app = createApp(createMemoryInput().input);
|
|
72
|
+
const registered = await request(app).post('/v1/auth/register').send({
|
|
73
|
+
email: 'filekey@ysk.hk',
|
|
74
|
+
password: 'password1',
|
|
75
|
+
displayName: 'File',
|
|
76
|
+
});
|
|
77
|
+
const jwt = registered.body.data.accessToken as string;
|
|
78
|
+
const created = await request(app)
|
|
79
|
+
.post('/v1/me/api-keys')
|
|
80
|
+
.set('authorization', `Bearer ${jwt}`)
|
|
81
|
+
.send({ name: 'files', permissions: ['file.upload'] });
|
|
82
|
+
const token = created.body.data.token as string;
|
|
83
|
+
const llm = await request(app)
|
|
84
|
+
.post('/v1/llm/complete')
|
|
85
|
+
.set('authorization', `Bearer ${token}`)
|
|
86
|
+
.send({ messages: [{ role: 'user', content: 'ping' }] });
|
|
87
|
+
expect(llm.status).toBe(403);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ILlmUsageRepository } from '../domain/usage-repository';
|
|
2
|
+
|
|
3
|
+
export const createMemoryLlmUsageRepository = (): ILlmUsageRepository & {
|
|
4
|
+
rows: Array<{ userId: string; model: string }>;
|
|
5
|
+
} => {
|
|
6
|
+
const rows: Array<{ userId: string; model: string }> = [];
|
|
7
|
+
return {
|
|
8
|
+
rows,
|
|
9
|
+
async create(input) {
|
|
10
|
+
rows.push({ userId: input.userId, model: input.model });
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
};
|