@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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/dist/add-capability.d.ts +4 -0
  3. package/dist/add-capability.d.ts.map +1 -0
  4. package/dist/add-capability.js +104 -0
  5. package/dist/add-capability.js.map +1 -0
  6. package/dist/add-module.d.ts +5 -0
  7. package/dist/add-module.d.ts.map +1 -0
  8. package/dist/add-module.js +210 -0
  9. package/dist/add-module.js.map +1 -0
  10. package/dist/agent-stubs.d.ts +3 -0
  11. package/dist/agent-stubs.d.ts.map +1 -0
  12. package/dist/agent-stubs.js +25 -0
  13. package/dist/agent-stubs.js.map +1 -0
  14. package/dist/apply-capability.d.ts +3 -0
  15. package/dist/apply-capability.d.ts.map +1 -0
  16. package/dist/apply-capability.js +189 -0
  17. package/dist/apply-capability.js.map +1 -0
  18. package/dist/capability-catalog.d.ts +25 -0
  19. package/dist/capability-catalog.d.ts.map +1 -0
  20. package/dist/capability-catalog.js +123 -0
  21. package/dist/capability-catalog.js.map +1 -0
  22. package/dist/capability-patches.d.ts +40 -0
  23. package/dist/capability-patches.d.ts.map +1 -0
  24. package/dist/capability-patches.js +616 -0
  25. package/dist/capability-patches.js.map +1 -0
  26. package/dist/check-agent.d.ts +9 -0
  27. package/dist/check-agent.d.ts.map +1 -0
  28. package/dist/check-agent.js +85 -0
  29. package/dist/check-agent.js.map +1 -0
  30. package/dist/generate-openapi.d.ts +2 -0
  31. package/dist/generate-openapi.d.ts.map +1 -0
  32. package/dist/generate-openapi.js +52 -0
  33. package/dist/generate-openapi.js.map +1 -0
  34. package/dist/help.d.ts +2 -0
  35. package/dist/help.d.ts.map +1 -0
  36. package/dist/help.js +54 -0
  37. package/dist/help.js.map +1 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +59 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/merge-prisma.d.ts +15 -0
  43. package/dist/merge-prisma.d.ts.map +1 -0
  44. package/dist/merge-prisma.js +90 -0
  45. package/dist/merge-prisma.js.map +1 -0
  46. package/dist/patch-text.d.ts +17 -0
  47. package/dist/patch-text.d.ts.map +1 -0
  48. package/dist/patch-text.js +72 -0
  49. package/dist/patch-text.js.map +1 -0
  50. package/dist/upgrade.d.ts +16 -0
  51. package/dist/upgrade.d.ts.map +1 -0
  52. package/dist/upgrade.js +110 -0
  53. package/dist/upgrade.js.map +1 -0
  54. package/package.json +41 -0
  55. package/templates/agent/skills/add-capability/SKILL.md +8 -0
  56. package/templates/agent/skills/add-module/SKILL.md +8 -0
  57. package/templates/agent/skills/envelope-api/SKILL.md +8 -0
  58. package/templates/agent/skills/fix-layers/SKILL.md +8 -0
  59. package/templates/agent/skills/new-product/SKILL.md +8 -0
  60. package/templates/agent/skills/verify-change/SKILL.md +8 -0
  61. package/templates/agent/ysk-kit.mdc +6 -0
  62. package/templates/capabilities/billing/apps/api/src/modules/billing/application/billing-service.ts +140 -0
  63. package/templates/capabilities/billing/apps/api/src/modules/billing/domain/billing-port.ts +23 -0
  64. package/templates/capabilities/billing/apps/api/src/modules/billing/domain/subscription-repository.ts +22 -0
  65. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing-router.ts +85 -0
  66. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing.app.test.ts +273 -0
  67. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/log-billing.ts +31 -0
  68. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/memory-subscription-repository.ts +27 -0
  69. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/prisma-subscription-repository.ts +52 -0
  70. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.test.ts +127 -0
  71. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.ts +160 -0
  72. package/templates/capabilities/billing/apps/web/src/features/orgs/billing-page.tsx +183 -0
  73. package/templates/capabilities/billing/packages/contracts/src/api/billing.ts +85 -0
  74. package/templates/capabilities/billing/packages/contracts/src/dto/billing.ts +59 -0
  75. package/templates/capabilities/billing/packages/sdk/src/resources/billing.ts +38 -0
  76. package/templates/capabilities/billing/packages/web-sdk/src/billing-hooks.ts +56 -0
  77. package/templates/capabilities/llm/apps/api/src/modules/llm/application/llm-service.ts +48 -0
  78. package/templates/capabilities/llm/apps/api/src/modules/llm/domain/usage-repository.ts +10 -0
  79. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm-router.ts +88 -0
  80. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm.app.test.ts +89 -0
  81. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/memory-usage-repository.ts +13 -0
  82. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/prisma-usage-repository.ts +17 -0
  83. package/templates/capabilities/llm/apps/web/src/features/llm/llm-page.tsx +53 -0
  84. package/templates/capabilities/llm/packages/contracts/src/api/llm.ts +26 -0
  85. package/templates/capabilities/llm/packages/contracts/src/dto/llm.ts +33 -0
  86. package/templates/capabilities/llm/packages/sdk/src/resources/llm.ts +68 -0
  87. package/templates/capabilities/llm/packages/web-sdk/src/llm-hooks.ts +12 -0
  88. package/templates/capabilities/push/apps/api/src/modules/devices/application/device-service.ts +42 -0
  89. package/templates/capabilities/push/apps/api/src/modules/devices/domain/device-repository.ts +22 -0
  90. package/templates/capabilities/push/apps/api/src/modules/devices/infra/device-router.ts +42 -0
  91. package/templates/capabilities/push/apps/api/src/modules/devices/infra/device.app.test.ts +34 -0
  92. package/templates/capabilities/push/apps/api/src/modules/devices/infra/memory-device-repository.ts +33 -0
  93. package/templates/capabilities/push/apps/api/src/modules/devices/infra/prisma-device-repository.ts +42 -0
  94. package/templates/capabilities/push/apps/mobile/src/adapters/push.ts +21 -0
  95. package/templates/capabilities/push/packages/contracts/src/api/devices.ts +33 -0
  96. package/templates/capabilities/push/packages/contracts/src/dto/device.ts +18 -0
  97. package/templates/capabilities/push/packages/sdk/src/resources/devices.ts +10 -0
  98. package/templates/capabilities/team/apps/api/src/modules/organizations/application/organization-service.ts +272 -0
  99. package/templates/capabilities/team/apps/api/src/modules/organizations/domain/organization-repository.ts +58 -0
  100. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/memory-organization-repository.ts +141 -0
  101. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization-router.ts +130 -0
  102. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization.app.test.ts +213 -0
  103. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/prisma-organization-repository.ts +142 -0
  104. package/templates/capabilities/team/apps/mobile/src/screens/invite-screen.tsx +40 -0
  105. package/templates/capabilities/team/apps/mobile/src/screens/org-detail-screen.tsx +154 -0
  106. package/templates/capabilities/team/apps/mobile/src/screens/orgs-screen.tsx +53 -0
  107. package/templates/capabilities/team/apps/web/src/features/orgs/invite-page.tsx +77 -0
  108. package/templates/capabilities/team/apps/web/src/features/orgs/org-detail-page.tsx +138 -0
  109. package/templates/capabilities/team/apps/web/src/features/orgs/orgs-page.tsx +73 -0
  110. package/templates/capabilities/team/packages/contracts/src/api/organizations.ts +154 -0
  111. package/templates/capabilities/team/packages/contracts/src/dto/organization.ts +58 -0
  112. package/templates/capabilities/team/packages/sdk/src/resources/organizations.ts +53 -0
  113. package/templates/capabilities/team/packages/web-sdk/src/organizations-hooks.ts +80 -0
  114. package/templates/module/api.ts.tmpl +32 -0
  115. package/templates/module/application-service.ts.tmpl +9 -0
  116. package/templates/module/domain-repository.ts.tmpl +15 -0
  117. package/templates/module/dto.ts.tmpl +21 -0
  118. package/templates/module/memory-repository.ts.tmpl +36 -0
  119. package/templates/module/model.prisma.tmpl +8 -0
  120. package/templates/module/prisma-repository.ts.tmpl +38 -0
  121. package/templates/module/router.ts.tmpl +32 -0
  122. package/templates/module/sdk-resource.ts.tmpl +17 -0
  123. package/templates/module/service.test.ts.tmpl +33 -0
  124. package/templates/module/web-hooks.ts.tmpl +24 -0
  125. package/templates/module/web-page.tsx.tmpl +69 -0
@@ -0,0 +1,273 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import request from 'supertest';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { createApp } from '../../../app';
5
+ import { createMemoryInput } from '../../../create-memory-input';
6
+ import { createBillingService } from '../application/billing-service';
7
+ import { createLogBilling } from './log-billing';
8
+ import { createMemorySubscriptionRepository } from './memory-subscription-repository';
9
+ import { createStripeBilling } from './stripe-billing';
10
+
11
+ describe('billing http', () => {
12
+ it('includes plans in OpenAPI', async () => {
13
+ const app = createApp(createMemoryInput().input);
14
+ const spec = await request(app).get('/openapi.json');
15
+ expect(spec.body.paths['/v1/billing/plans']).toBeDefined();
16
+ });
17
+
18
+ it('activates a subscription from a signed Stripe webhook', async () => {
19
+ const mem = createMemoryInput();
20
+ const app = createApp(mem.input);
21
+ const missing = await request(app).post('/v1/billing/webhook').send({});
22
+ expect(missing.status).toBe(404);
23
+ const secret = 'whsec_test';
24
+ const stripeApp = createApp({ ...mem.input, stripeWebhookSecret: secret });
25
+ const registered = await request(stripeApp).post('/v1/auth/register').send({
26
+ email: 'stripe@ysk.hk',
27
+ password: 'password1',
28
+ displayName: 'Stripe',
29
+ });
30
+ const jwt = registered.body.data.accessToken as string;
31
+ const org = await request(stripeApp)
32
+ .post('/v1/organizations')
33
+ .set('authorization', `Bearer ${jwt}`)
34
+ .send({ name: 'Pay' });
35
+ const organizationId = org.body.data.id as string;
36
+ const payload = JSON.stringify({
37
+ type: 'checkout.session.completed',
38
+ data: {
39
+ object: {
40
+ customer: 'cus_1',
41
+ metadata: { organizationId, planCode: 'pro', seatCount: '2' },
42
+ },
43
+ },
44
+ });
45
+ const unsigned = await request(stripeApp)
46
+ .post('/v1/billing/webhook')
47
+ .set('content-type', 'application/json')
48
+ .send(payload);
49
+ expect(unsigned.status).toBe(401);
50
+ const t = String(Math.floor(Date.now() / 1000));
51
+ const v1 = createHmac('sha256', secret).update(`${t}.${payload}`).digest('hex');
52
+ const signed = await request(stripeApp)
53
+ .post('/v1/billing/webhook')
54
+ .set('content-type', 'application/json')
55
+ .set('stripe-signature', `t=${t},v1=${v1}`)
56
+ .send(payload);
57
+ expect(signed.status).toBe(200);
58
+ const sub = await request(stripeApp)
59
+ .get(`/v1/billing/subscription?organizationId=${organizationId}`)
60
+ .set('authorization', `Bearer ${jwt}`);
61
+ expect(sub.body.data.planCode).toBe('pro');
62
+ expect(sub.body.data.status).toBe('active');
63
+ expect(sub.body.data.seatCount).toBe(2);
64
+ });
65
+
66
+ it('rejects Stripe portal before a customer exists', async () => {
67
+ const mem = createMemoryInput();
68
+ const billingService = createBillingService({
69
+ subscriptions: createMemorySubscriptionRepository(),
70
+ billing: createStripeBilling({
71
+ secretKey: 'sk_test_x',
72
+ pricePro: 'price_pro',
73
+ fetchImpl: (async () => new Response('{}', { status: 500 })) as typeof fetch,
74
+ }),
75
+ orgs: mem.orgs,
76
+ });
77
+ const stripeApp = createApp({ ...mem.input, billingService });
78
+ const registered = await request(stripeApp).post('/v1/auth/register').send({
79
+ email: 'portal@ysk.hk',
80
+ password: 'password1',
81
+ displayName: 'Portal',
82
+ });
83
+ const jwt = registered.body.data.accessToken as string;
84
+ const org = await request(stripeApp)
85
+ .post('/v1/organizations')
86
+ .set('authorization', `Bearer ${jwt}`)
87
+ .send({ name: 'Portal Co' });
88
+ const denied = await request(stripeApp)
89
+ .post('/v1/billing/portal')
90
+ .set('authorization', `Bearer ${jwt}`)
91
+ .send({
92
+ organizationId: org.body.data.id,
93
+ returnUrl: 'http://localhost:5173/billing',
94
+ });
95
+ expect(denied.status).toBe(409);
96
+ });
97
+
98
+ it('lists plans publicly and checks out a pro subscription', async () => {
99
+ const mem = createMemoryInput();
100
+ const app = createApp(mem.input);
101
+ const plans = await request(app).get('/v1/billing/plans');
102
+ expect(plans.status).toBe(200);
103
+ expect(plans.body.data.some((row: { code: string }) => row.code === 'pro')).toBe(true);
104
+ const denied = await request(app).post('/v1/billing/checkout').send({
105
+ organizationId: '11111111-1111-4111-8111-111111111111',
106
+ planCode: 'pro',
107
+ successUrl: 'http://localhost:5173/ok',
108
+ cancelUrl: 'http://localhost:5173/no',
109
+ });
110
+ expect(denied.status).toBe(401);
111
+ const registered = await request(app).post('/v1/auth/register').send({
112
+ email: 'pay@ysk.hk',
113
+ password: 'password1',
114
+ displayName: 'Pay',
115
+ });
116
+ const jwt = registered.body.data.accessToken as string;
117
+ const org = await request(app)
118
+ .post('/v1/organizations')
119
+ .set('authorization', `Bearer ${jwt}`)
120
+ .send({ name: 'Pay Co' });
121
+ const organizationId = org.body.data.id as string;
122
+ const me = await request(app)
123
+ .get(`/v1/billing/subscription?organizationId=${organizationId}`)
124
+ .set('authorization', `Bearer ${jwt}`);
125
+ expect(me.body.data.planCode).toBe('free');
126
+ expect(me.body.data.seatCount).toBe(1);
127
+ const tooFewSeats = await request(app)
128
+ .post('/v1/billing/checkout')
129
+ .set('authorization', `Bearer ${jwt}`)
130
+ .send({
131
+ organizationId,
132
+ planCode: 'pro',
133
+ successUrl: 'http://localhost:5173/ok',
134
+ cancelUrl: 'http://localhost:5173/no',
135
+ seatCount: 0,
136
+ });
137
+ expect(tooFewSeats.status).toBe(422);
138
+ const checkout = await request(app)
139
+ .post('/v1/billing/checkout')
140
+ .set('authorization', `Bearer ${jwt}`)
141
+ .send({
142
+ organizationId,
143
+ planCode: 'pro',
144
+ successUrl: 'http://localhost:5173/ok',
145
+ cancelUrl: 'http://localhost:5173/no',
146
+ seatCount: 2,
147
+ });
148
+ expect(checkout.status).toBe(200);
149
+ expect(checkout.body.data.url).toContain('plan=pro');
150
+ expect(checkout.body.data.url).toContain('seats=2');
151
+ const sub = await request(app)
152
+ .get(`/v1/billing/subscription?organizationId=${organizationId}`)
153
+ .set('authorization', `Bearer ${jwt}`);
154
+ expect(sub.body.data.planCode).toBe('pro');
155
+ expect(sub.body.data.status).toBe('active');
156
+ expect(sub.body.data.seatCount).toBe(2);
157
+ const canceled = await request(app)
158
+ .post('/v1/billing/cancel')
159
+ .set('authorization', `Bearer ${jwt}`)
160
+ .send({ organizationId });
161
+ expect(canceled.status).toBe(200);
162
+ const after = await request(app)
163
+ .get(`/v1/billing/subscription?organizationId=${organizationId}`)
164
+ .set('authorization', `Bearer ${jwt}`);
165
+ expect(after.body.data.status).toBe('canceled');
166
+ const portal = await request(app)
167
+ .post('/v1/billing/portal')
168
+ .set('authorization', `Bearer ${jwt}`)
169
+ .send({ organizationId, returnUrl: 'http://localhost:5173/billing' });
170
+ expect(portal.status).toBe(200);
171
+ expect(portal.body.data.url).toContain('billing.local/portal');
172
+ const invoices = await request(app)
173
+ .get(`/v1/billing/invoices?organizationId=${organizationId}`)
174
+ .set('authorization', `Bearer ${jwt}`);
175
+ expect(invoices.status).toBe(200);
176
+ expect(invoices.body.data).toEqual([]);
177
+ const pdf = await request(app)
178
+ .get(`/v1/billing/invoices/in_missing/pdf?organizationId=${organizationId}`)
179
+ .set('authorization', `Bearer ${jwt}`)
180
+ .redirects(0);
181
+ expect(pdf.status).toBe(404);
182
+ });
183
+
184
+ it('forbids org MEMBER checkout and rejects seatCount below members', async () => {
185
+ const mem = createMemoryInput();
186
+ const app = createApp(mem.input);
187
+ const ownerRes = await request(app).post('/v1/auth/register').send({
188
+ email: 'bill-owner@ysk.hk',
189
+ password: 'password1',
190
+ displayName: 'Owner',
191
+ });
192
+ await request(app).post('/v1/auth/register').send({
193
+ email: 'bill-member@ysk.hk',
194
+ password: 'password1',
195
+ displayName: 'Member',
196
+ });
197
+ const ownerToken = ownerRes.body.data.accessToken as string;
198
+ const org = await request(app)
199
+ .post('/v1/organizations')
200
+ .set('authorization', `Bearer ${ownerToken}`)
201
+ .send({ name: 'Seats Co' });
202
+ const organizationId = org.body.data.id as string;
203
+ await request(app)
204
+ .post(`/v1/organizations/${organizationId}/invites`)
205
+ .set('authorization', `Bearer ${ownerToken}`)
206
+ .send({ email: 'bill-member@ysk.hk', role: 'MEMBER' });
207
+ const inviteJob = mem.jobs.find(
208
+ (job) => job.name === 'email.send' && job.payload.vars?.inviteUrl,
209
+ );
210
+ const inviteToken = new URL(inviteJob?.payload.vars?.inviteUrl ?? 'http://x').searchParams.get(
211
+ 'token',
212
+ );
213
+ await request(app).post('/v1/organizations/invites/accept').send({ token: inviteToken });
214
+ const memberLogin = await request(app).post('/v1/auth/login').send({
215
+ email: 'bill-member@ysk.hk',
216
+ password: 'password1',
217
+ });
218
+ const memberDenied = await request(app)
219
+ .post('/v1/billing/checkout')
220
+ .set('authorization', `Bearer ${memberLogin.body.data.accessToken}`)
221
+ .send({
222
+ organizationId,
223
+ planCode: 'pro',
224
+ successUrl: 'http://localhost:5173/ok',
225
+ cancelUrl: 'http://localhost:5173/no',
226
+ seatCount: 2,
227
+ });
228
+ expect(memberDenied.status).toBe(403);
229
+ const tooFew = await request(app)
230
+ .post('/v1/billing/checkout')
231
+ .set('authorization', `Bearer ${ownerToken}`)
232
+ .send({
233
+ organizationId,
234
+ planCode: 'pro',
235
+ successUrl: 'http://localhost:5173/ok',
236
+ cancelUrl: 'http://localhost:5173/no',
237
+ seatCount: 1,
238
+ });
239
+ expect(tooFew.status).toBe(422);
240
+ });
241
+
242
+ it('redirects to an invoice PDF when the port returns a URL', async () => {
243
+ const mem = createMemoryInput();
244
+ const log = createLogBilling();
245
+ const billingService = createBillingService({
246
+ subscriptions: createMemorySubscriptionRepository(),
247
+ billing: {
248
+ checkout: (input) => log.checkout(input),
249
+ portal: (input) => log.portal(input),
250
+ listInvoices: (input) => log.listInvoices(input),
251
+ getInvoicePdf: async () => ({ url: 'https://files.stripe.com/x.pdf' }),
252
+ },
253
+ orgs: mem.orgs,
254
+ });
255
+ const pdfApp = createApp({ ...mem.input, billingService });
256
+ const registered = await request(pdfApp).post('/v1/auth/register').send({
257
+ email: 'pdf@ysk.hk',
258
+ password: 'password1',
259
+ displayName: 'Pdf',
260
+ });
261
+ const org = await mem.orgs.createWithOwner({
262
+ name: 'Pdf Co',
263
+ ownerUserId: registered.body.data.user.id as string,
264
+ });
265
+ await billingService.activate(org.id, 'pro', 'cus_1', 1);
266
+ const pdf = await request(pdfApp)
267
+ .get(`/v1/billing/invoices/in_1/pdf?organizationId=${org.id}`)
268
+ .set('authorization', `Bearer ${registered.body.data.accessToken}`)
269
+ .redirects(0);
270
+ expect(pdf.status).toBe(302);
271
+ expect(pdf.headers.location).toBe('https://files.stripe.com/x.pdf');
272
+ });
273
+ });
@@ -0,0 +1,31 @@
1
+ import type { IBillingPort } from '../domain/billing-port';
2
+
3
+ export const createLogBilling = (
4
+ sink: Array<{
5
+ organizationId: string;
6
+ planCode: string;
7
+ seatCount: number;
8
+ urlSuffix: string;
9
+ }> = [],
10
+ ): IBillingPort & { sink: typeof sink } => ({
11
+ sink,
12
+ async checkout(input) {
13
+ const url = `https://billing.local/checkout?plan=${encodeURIComponent(input.planCode)}&seats=${input.seatCount}`;
14
+ sink.push({
15
+ organizationId: input.organizationId,
16
+ planCode: input.planCode,
17
+ seatCount: input.seatCount,
18
+ urlSuffix: url.slice(-12),
19
+ });
20
+ return { url };
21
+ },
22
+ async portal() {
23
+ return { url: 'https://billing.local/portal' };
24
+ },
25
+ async listInvoices() {
26
+ return [];
27
+ },
28
+ async getInvoicePdf() {
29
+ return null;
30
+ },
31
+ });
@@ -0,0 +1,27 @@
1
+ import type {
2
+ ISubscriptionRepository,
3
+ SubscriptionRecord,
4
+ } from '../domain/subscription-repository';
5
+
6
+ export const createMemorySubscriptionRepository = (): ISubscriptionRepository => {
7
+ const rows = new Map<string, SubscriptionRecord>();
8
+ return {
9
+ async findByOrganizationId(organizationId) {
10
+ return rows.get(organizationId) ?? null;
11
+ },
12
+ async upsert(input) {
13
+ const existing = rows.get(input.organizationId);
14
+ const row: SubscriptionRecord = {
15
+ id: existing?.id ?? crypto.randomUUID(),
16
+ createdAt: existing?.createdAt ?? new Date(),
17
+ organizationId: input.organizationId,
18
+ planCode: input.planCode,
19
+ status: input.status,
20
+ currentPeriodEnd: input.currentPeriodEnd,
21
+ seatCount: input.seatCount,
22
+ };
23
+ rows.set(input.organizationId, row);
24
+ return row;
25
+ },
26
+ };
27
+ };
@@ -0,0 +1,52 @@
1
+ import type { BillingPlanCode, SubscriptionStatus } from '@ysk-kit/contracts';
2
+ import type { PrismaClient } from '../../../generated/prisma/client';
3
+ import type {
4
+ ISubscriptionRepository,
5
+ SubscriptionRecord,
6
+ } from '../domain/subscription-repository';
7
+
8
+ const toRecord = (row: {
9
+ id: string;
10
+ organizationId: string;
11
+ planCode: string;
12
+ status: string;
13
+ currentPeriodEnd: Date | null;
14
+ seatCount: number;
15
+ createdAt: Date;
16
+ }): SubscriptionRecord => ({
17
+ id: row.id,
18
+ organizationId: row.organizationId,
19
+ planCode: row.planCode as BillingPlanCode,
20
+ status: row.status as SubscriptionStatus,
21
+ currentPeriodEnd: row.currentPeriodEnd,
22
+ seatCount: row.seatCount,
23
+ createdAt: row.createdAt,
24
+ });
25
+
26
+ export const createPrismaSubscriptionRepository = (
27
+ prisma: PrismaClient,
28
+ ): ISubscriptionRepository => ({
29
+ async findByOrganizationId(organizationId) {
30
+ const row = await prisma.subscription.findUnique({ where: { organizationId } });
31
+ return row ? toRecord(row) : null;
32
+ },
33
+ async upsert(input) {
34
+ const row = await prisma.subscription.upsert({
35
+ where: { organizationId: input.organizationId },
36
+ create: {
37
+ organizationId: input.organizationId,
38
+ planCode: input.planCode,
39
+ status: input.status,
40
+ currentPeriodEnd: input.currentPeriodEnd,
41
+ seatCount: input.seatCount,
42
+ },
43
+ update: {
44
+ planCode: input.planCode,
45
+ status: input.status,
46
+ currentPeriodEnd: input.currentPeriodEnd,
47
+ seatCount: input.seatCount,
48
+ },
49
+ });
50
+ return toRecord(row);
51
+ },
52
+ });
@@ -0,0 +1,127 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createStripeBilling, verifyStripeSignature } from './stripe-billing';
4
+
5
+ describe('stripe billing', () => {
6
+ it('creates a pending checkout session', async () => {
7
+ const bodies: string[] = [];
8
+ const billing = createStripeBilling({
9
+ secretKey: 'sk_test_x',
10
+ pricePro: 'price_pro_1',
11
+ fetchImpl: (async (url, init) => {
12
+ bodies.push(String(init?.body));
13
+ expect(String(url)).toContain('/v1/checkout/sessions');
14
+ const headers = (init?.headers ?? {}) as { authorization?: string };
15
+ expect(headers.authorization).toBe('Bearer sk_test_x');
16
+ return new Response(JSON.stringify({ url: 'https://checkout.stripe.com/c/pay/cs_test' }), {
17
+ status: 200,
18
+ });
19
+ }) as typeof fetch,
20
+ });
21
+ const result = await billing.checkout({
22
+ organizationId: '11111111-1111-4111-8111-111111111111',
23
+ planCode: 'pro',
24
+ successUrl: 'https://app.example/ok',
25
+ cancelUrl: 'https://app.example/no',
26
+ seatCount: 3,
27
+ });
28
+ expect(result.url).toContain('checkout.stripe.com');
29
+ expect(result.pending).toBe(true);
30
+ expect(bodies[0]).toContain('price_pro_1');
31
+ expect(bodies[0]).toContain('11111111-1111-4111-8111-111111111111');
32
+ expect(bodies[0]).toContain('line_items%5B0%5D%5Bquantity%5D=3');
33
+ expect(bodies[0]).toContain('automatic_tax%5Benabled%5D=true');
34
+ expect(bodies[0]).toContain('billing_address_collection=required');
35
+ });
36
+
37
+ it('opens a billing portal session', async () => {
38
+ const bodies: string[] = [];
39
+ const billing = createStripeBilling({
40
+ secretKey: 'sk_test_x',
41
+ pricePro: 'price_pro_1',
42
+ fetchImpl: (async (_url, init) => {
43
+ bodies.push(String(init?.body));
44
+ return new Response(JSON.stringify({ url: 'https://billing.stripe.com/p/session' }), {
45
+ status: 200,
46
+ });
47
+ }) as typeof fetch,
48
+ });
49
+ const result = await billing.portal({
50
+ returnUrl: 'https://app.example/billing',
51
+ customerId: 'cus_123',
52
+ });
53
+ expect(result.url).toContain('billing.stripe.com');
54
+ expect(bodies[0]).toContain('cus_123');
55
+ });
56
+
57
+ it('maps Stripe invoices', async () => {
58
+ const billing = createStripeBilling({
59
+ secretKey: 'sk_test_x',
60
+ pricePro: 'price_pro_1',
61
+ fetchImpl: (async (url) => {
62
+ expect(String(url)).toContain('customer=cus_123');
63
+ return new Response(
64
+ JSON.stringify({
65
+ data: [
66
+ {
67
+ id: 'in_1',
68
+ number: 'ABC-1',
69
+ status: 'paid',
70
+ amount_due: 9900,
71
+ currency: 'hkd',
72
+ hosted_invoice_url: 'https://invoice.stripe.com/i/1',
73
+ created: 1_700_000_000,
74
+ },
75
+ ],
76
+ }),
77
+ { status: 200 },
78
+ );
79
+ }) as typeof fetch,
80
+ });
81
+ const rows = await billing.listInvoices({ customerId: 'cus_123' });
82
+ expect(rows).toHaveLength(1);
83
+ expect(rows[0]?.id).toBe('in_1');
84
+ expect(rows[0]?.amountDue).toBe(9900);
85
+ expect(rows[0]?.hostedInvoiceUrl).toContain('invoice.stripe.com');
86
+ });
87
+
88
+ it('returns invoice_pdf when the customer matches', async () => {
89
+ const billing = createStripeBilling({
90
+ secretKey: 'sk_test_x',
91
+ pricePro: 'price_pro_1',
92
+ fetchImpl: (async () =>
93
+ new Response(
94
+ JSON.stringify({
95
+ customer: 'cus_123',
96
+ invoice_pdf: 'https://files.stripe.com/invoices/in_1.pdf',
97
+ }),
98
+ { status: 200 },
99
+ )) as typeof fetch,
100
+ });
101
+ await expect(
102
+ billing.getInvoicePdf({ customerId: 'cus_123', invoiceId: 'in_1' }),
103
+ ).resolves.toEqual({ url: 'https://files.stripe.com/invoices/in_1.pdf' });
104
+ await expect(
105
+ billing.getInvoicePdf({ customerId: 'cus_other', invoiceId: 'in_1' }),
106
+ ).resolves.toBeNull();
107
+ });
108
+
109
+ it('verifies Stripe-Signature', () => {
110
+ const secret = 'whsec_test';
111
+ const payload = Buffer.from(
112
+ JSON.stringify({
113
+ type: 'checkout.session.completed',
114
+ data: { object: { metadata: { organizationId: 'o1', planCode: 'pro', seatCount: '2' } } },
115
+ }),
116
+ );
117
+ const t = '1700000000';
118
+ const v1 = createHmac('sha256', secret)
119
+ .update(`${t}.${payload.toString('utf8')}`)
120
+ .digest('hex');
121
+ const event = verifyStripeSignature(payload, `t=${t},v1=${v1}`, secret, 1_700_000_000_000);
122
+ expect(event.type).toBe('checkout.session.completed');
123
+ expect(() =>
124
+ verifyStripeSignature(payload, `t=${t},v1=dead`, secret, 1_700_000_000_000),
125
+ ).toThrow();
126
+ });
127
+ });
@@ -0,0 +1,160 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import type { InvoiceDto } from '@ysk-kit/contracts';
3
+ import { AppError } from '@ysk-kit/domain-kernel';
4
+ import type { IBillingPort } from '../domain/billing-port';
5
+
6
+ export const verifyStripeSignature = (
7
+ raw: Buffer,
8
+ header: string,
9
+ secret: string,
10
+ nowMs = Date.now(),
11
+ ): {
12
+ type: string;
13
+ data: { object: { metadata?: Record<string, string>; customer?: string } };
14
+ } => {
15
+ const parts = Object.fromEntries(
16
+ header.split(',').map((item) => {
17
+ const [key, ...rest] = item.split('=');
18
+ return [key ?? '', rest.join('=')];
19
+ }),
20
+ );
21
+ const timestamp = parts.t;
22
+ const signature = parts.v1;
23
+ if (!timestamp || !signature) throw new AppError('UNAUTHENTICATED', 'Invalid Stripe signature');
24
+ const age = Math.abs(nowMs - Number(timestamp) * 1000);
25
+ if (Number.isNaN(Number(timestamp)) || age > 5 * 60 * 1000) {
26
+ throw new AppError('UNAUTHENTICATED', 'Invalid Stripe signature');
27
+ }
28
+ const expected = createHmac('sha256', secret)
29
+ .update(`${timestamp}.${raw.toString('utf8')}`)
30
+ .digest('hex');
31
+ const a = Buffer.from(signature);
32
+ const b = Buffer.from(expected);
33
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
34
+ throw new AppError('UNAUTHENTICATED', 'Invalid Stripe signature');
35
+ }
36
+ return JSON.parse(raw.toString('utf8')) as {
37
+ type: string;
38
+ data: { object: { metadata?: Record<string, string>; customer?: string } };
39
+ };
40
+ };
41
+
42
+ export const createStripeBilling = (opts: {
43
+ secretKey: string;
44
+ pricePro: string;
45
+ fetchImpl?: typeof fetch;
46
+ }): IBillingPort => {
47
+ const fetchImpl = opts.fetchImpl ?? fetch;
48
+ return {
49
+ requiresCustomer: true,
50
+ async getInvoicePdf(input) {
51
+ const res = await fetchImpl(
52
+ `https://api.stripe.com/v1/invoices/${encodeURIComponent(input.invoiceId)}`,
53
+ { headers: { authorization: `Bearer ${opts.secretKey}` } },
54
+ );
55
+ if (!res.ok) return null;
56
+ const json = (await res.json()) as {
57
+ customer?: string;
58
+ invoice_pdf?: string | null;
59
+ hosted_invoice_url?: string | null;
60
+ };
61
+ if (json.customer !== input.customerId) return null;
62
+ const url = json.invoice_pdf ?? json.hosted_invoice_url;
63
+ return url ? { url } : null;
64
+ },
65
+ async listInvoices(input) {
66
+ const res = await fetchImpl(
67
+ `https://api.stripe.com/v1/invoices?customer=${encodeURIComponent(input.customerId)}&limit=20`,
68
+ { headers: { authorization: `Bearer ${opts.secretKey}` } },
69
+ );
70
+ if (!res.ok) throw new AppError('INTERNAL', 'Invoices failed');
71
+ const json = (await res.json()) as {
72
+ data?: Array<{
73
+ id: string;
74
+ number?: string | null;
75
+ status?: string;
76
+ amount_due?: number;
77
+ currency?: string;
78
+ hosted_invoice_url?: string | null;
79
+ created?: number;
80
+ }>;
81
+ };
82
+ return (json.data ?? []).map(
83
+ (row): InvoiceDto => ({
84
+ id: row.id,
85
+ number: row.number ?? null,
86
+ status: row.status ?? 'open',
87
+ amountDue: row.amount_due ?? 0,
88
+ currency: row.currency ?? 'hkd',
89
+ hostedInvoiceUrl: row.hosted_invoice_url ?? null,
90
+ createdAt: new Date((row.created ?? 0) * 1000).toISOString(),
91
+ }),
92
+ );
93
+ },
94
+ async portal(input) {
95
+ const res = await fetchImpl('https://api.stripe.com/v1/billing_portal/sessions', {
96
+ method: 'POST',
97
+ headers: {
98
+ authorization: `Bearer ${opts.secretKey}`,
99
+ 'content-type': 'application/x-www-form-urlencoded',
100
+ },
101
+ body: new URLSearchParams({
102
+ customer: input.customerId,
103
+ return_url: input.returnUrl,
104
+ }),
105
+ });
106
+ if (!res.ok) throw new AppError('INTERNAL', 'Portal failed');
107
+ const json = (await res.json()) as { url?: string };
108
+ if (!json.url) throw new AppError('INTERNAL', 'Portal failed');
109
+ return { url: json.url };
110
+ },
111
+ async checkout(input) {
112
+ if (input.planCode !== 'pro') throw new AppError('VALIDATION_FAILED', 'Unknown plan');
113
+ const params = new URLSearchParams({
114
+ mode: 'subscription',
115
+ success_url: input.successUrl,
116
+ cancel_url: input.cancelUrl,
117
+ client_reference_id: input.organizationId,
118
+ billing_address_collection: 'required',
119
+ 'automatic_tax[enabled]': 'true',
120
+ 'line_items[0][price]': opts.pricePro,
121
+ 'line_items[0][quantity]': String(input.seatCount),
122
+ 'metadata[organizationId]': input.organizationId,
123
+ 'metadata[planCode]': input.planCode,
124
+ 'metadata[seatCount]': String(input.seatCount),
125
+ });
126
+ if (input.customerId) {
127
+ params.set('customer', input.customerId);
128
+ params.set('customer_update[address]', 'auto');
129
+ }
130
+ const res = await fetchImpl('https://api.stripe.com/v1/checkout/sessions', {
131
+ method: 'POST',
132
+ headers: {
133
+ authorization: `Bearer ${opts.secretKey}`,
134
+ 'content-type': 'application/x-www-form-urlencoded',
135
+ },
136
+ body: params,
137
+ });
138
+ if (!res.ok) throw new AppError('INTERNAL', 'Checkout failed');
139
+ const json = (await res.json()) as { url?: string };
140
+ if (!json.url) throw new AppError('INTERNAL', 'Checkout failed');
141
+ return { url: json.url, pending: true };
142
+ },
143
+ };
144
+ };
145
+
146
+ export const createBillingFromEnv = (
147
+ env: {
148
+ STRIPE_SECRET_KEY?: string | undefined;
149
+ STRIPE_PRICE_PRO?: string | undefined;
150
+ },
151
+ log: IBillingPort,
152
+ ): IBillingPort => {
153
+ if (env.STRIPE_SECRET_KEY && env.STRIPE_PRICE_PRO) {
154
+ return createStripeBilling({
155
+ secretKey: env.STRIPE_SECRET_KEY,
156
+ pricePro: env.STRIPE_PRICE_PRO,
157
+ });
158
+ }
159
+ return log;
160
+ };