@stratacommerce/stripe-connect 0.0.0-stage → 0.1.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 +10 -2
- package/dist/admin/fact-list.d.ts +5 -0
- package/dist/admin/fact-list.js +9 -0
- package/dist/admin/index.d.ts +2 -0
- package/dist/admin/index.js +13 -0
- package/dist/admin/messages.d.ts +79 -0
- package/dist/admin/messages.js +80 -0
- package/dist/admin/order-panel.d.ts +2 -0
- package/dist/admin/order-panel.js +35 -0
- package/dist/admin/settings-panel.d.ts +4 -0
- package/dist/admin/settings-panel.js +29 -0
- package/dist/enrollments.d.ts +92 -0
- package/dist/enrollments.js +172 -0
- package/dist/facts.d.ts +279 -0
- package/dist/facts.js +116 -0
- package/dist/identity.d.ts +17 -0
- package/dist/identity.js +34 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +30 -0
- package/dist/migrations.d.ts +2 -0
- package/dist/migrations.js +6 -0
- package/dist/provider.d.ts +63 -0
- package/dist/provider.js +195 -0
- package/dist/routes.d.ts +5 -0
- package/dist/routes.js +54 -0
- package/dist/schema.d.ts +1110 -0
- package/dist/schema.js +26 -0
- package/dist/storefront/index.d.ts +18 -0
- package/dist/storefront/index.js +72 -0
- package/dist/storefront/stripe-context.d.ts +1 -0
- package/dist/storefront/stripe-context.js +10 -0
- package/package.json +74 -3
package/dist/provider.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { and, eq } from 'drizzle-orm';
|
|
3
|
+
import { AppError } from '@stratacommerce/extension-sdk';
|
|
4
|
+
import { connectConfigurationSchema, connectFactsPageSchema, connectFactsAckResultSchema, CONNECT_API_BASE, CONNECT_API_PATHS, connectOperationResultSchema, connectPath } from '@stratacommerce/hub-protocol';
|
|
5
|
+
import { CONNECT_PROVIDER, connectIdentity, connectScopeOf } from './identity.js';
|
|
6
|
+
import { enrollments, financialFacts } from './schema.js';
|
|
7
|
+
// Core never calls Stripe. The Hub holds the platform key; this provider
|
|
8
|
+
// calls the Hub's Connect API with the enrollment's payment client token.
|
|
9
|
+
export class ConnectHubClient {
|
|
10
|
+
origin;
|
|
11
|
+
token;
|
|
12
|
+
transport;
|
|
13
|
+
constructor(origin, token, transport = fetch) {
|
|
14
|
+
this.origin = origin;
|
|
15
|
+
this.token = token;
|
|
16
|
+
this.transport = transport;
|
|
17
|
+
}
|
|
18
|
+
async request(path, body) {
|
|
19
|
+
if (!path.startsWith(`${CONNECT_API_BASE}/`) || !/^[a-zA-Z0-9/?=&%_.-]+$/.test(path) || new URL(path, this.origin).origin !== this.origin)
|
|
20
|
+
throw new AppError('Invalid payment service path', 'PAYMENT_SCOPE_INVALID', 400);
|
|
21
|
+
try {
|
|
22
|
+
const response = await this.transport(`${this.origin}${path}`, { method: body === undefined ? 'GET' : 'POST', headers: { Authorization: `Bearer ${this.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
23
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(15000) });
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
throw new AppError('Strata payment service is unavailable; retry this payment operation', response.status === 409 ? 'PAYMENT_AUTHORIZATION_UNAVAILABLE' : 'PAYMENT_UNAVAILABLE', response.status >= 500 ? 503 : 409);
|
|
26
|
+
const bytes = await response.text();
|
|
27
|
+
if (bytes.length > 1048576)
|
|
28
|
+
throw Error();
|
|
29
|
+
return JSON.parse(bytes);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof AppError)
|
|
33
|
+
throw error;
|
|
34
|
+
throw new AppError('Strata payment service is unavailable; retry this payment operation', 'PAYMENT_UNAVAILABLE', 503);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export class ConnectPaymentProvider {
|
|
39
|
+
services;
|
|
40
|
+
identity;
|
|
41
|
+
recipientId;
|
|
42
|
+
makeClient;
|
|
43
|
+
name = CONNECT_PROVIDER;
|
|
44
|
+
accountId;
|
|
45
|
+
scope;
|
|
46
|
+
constructor(services, identity, recipientId, makeClient) {
|
|
47
|
+
this.services = services;
|
|
48
|
+
this.identity = identity;
|
|
49
|
+
this.recipientId = recipientId;
|
|
50
|
+
this.makeClient = makeClient;
|
|
51
|
+
this.scope = connectScopeOf(identity);
|
|
52
|
+
this.accountId = this.scope.stripeAccountId;
|
|
53
|
+
}
|
|
54
|
+
get db() { return this.services.ctx.db; }
|
|
55
|
+
async client() {
|
|
56
|
+
if (this.makeClient)
|
|
57
|
+
return this.makeClient();
|
|
58
|
+
const enrollment = await this.services.enrollments.credentials(this.recipientId);
|
|
59
|
+
if (enrollment.id !== this.identity.connectionId && !enrollment.recoveryBindingIds.includes(this.identity.connectionId))
|
|
60
|
+
throw new AppError('Historical payment recovery was not approved', 'PAYMENT_SCOPE_INVALID', 409);
|
|
61
|
+
return new ConnectHubClient(enrollment.scope.hubOrigin, enrollment.token);
|
|
62
|
+
}
|
|
63
|
+
async original(paymentIntentId) {
|
|
64
|
+
const rows = await this.services.ctx.payments.attemptsForSession(paymentIntentId);
|
|
65
|
+
const row = rows.find((r) => r.provider === CONNECT_PROVIDER && isDeepStrictEqual(r.identity, this.identity));
|
|
66
|
+
if (!row)
|
|
67
|
+
throw new AppError('Original payment attempt not found', 'PAYMENT_SCOPE_INVALID', 409);
|
|
68
|
+
return row;
|
|
69
|
+
}
|
|
70
|
+
async sync() {
|
|
71
|
+
const client = await this.client();
|
|
72
|
+
for (let page = 0; page < 20; page++) {
|
|
73
|
+
const response = connectFactsPageSchema.safeParse(await client.request(CONNECT_API_PATHS.facts));
|
|
74
|
+
if (!response.success)
|
|
75
|
+
throw new AppError('Invalid payment facts', 'PAYMENT_FACTS_INVALID', 503);
|
|
76
|
+
const ids = [];
|
|
77
|
+
for (const delivery of response.data.deliveries)
|
|
78
|
+
ids.push((await this.services.facts.receive(delivery)).deliveryId);
|
|
79
|
+
if (ids.length && !connectFactsAckResultSchema.safeParse(await client.request(CONNECT_API_PATHS.factsAck, { deliveryIds: ids })).success)
|
|
80
|
+
throw new AppError('Invalid payment acknowledgement', 'PAYMENT_FACTS_INVALID', 503);
|
|
81
|
+
if (ids.length < 100)
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
throw new AppError('Payment backlog needs reconciliation', 'PAYMENT_BACKLOG_PENDING', 503);
|
|
85
|
+
}
|
|
86
|
+
async createSession(input) {
|
|
87
|
+
const client = await this.client(), result = connectOperationResultSchema.parse(await client.request(CONNECT_API_PATHS.checkouts, { operationId: input.attemptId, attemptId: input.attemptId, orderReference: input.orderId, amountMinor: input.totalCents, currency: input.currency }));
|
|
88
|
+
if (!isDeepStrictEqual(result.scope, this.scope) || result.operationId !== input.attemptId || result.amountMinor !== input.totalCents || result.currency !== input.currency)
|
|
89
|
+
throw new AppError('Payment response scope does not match', 'PAYMENT_SCOPE_INVALID', 409);
|
|
90
|
+
if (!result.paymentIntentId || !result.clientSecret || result.state !== 'succeeded')
|
|
91
|
+
throw new AppError('Payment outcome requires recovery; retry this checkout', 'PAYMENT_OUTCOME_UNKNOWN', 503);
|
|
92
|
+
const configuration = connectConfigurationSchema.safeParse(await client.request(CONNECT_API_PATHS.configuration));
|
|
93
|
+
if (!configuration.success || !isDeepStrictEqual(configuration.data.scope, this.scope))
|
|
94
|
+
throw new AppError('Payment public configuration does not match', 'PAYMENT_SCOPE_INVALID', 409);
|
|
95
|
+
const config = configuration.data;
|
|
96
|
+
return { sessionId: result.paymentIntentId, accountId: this.accountId,
|
|
97
|
+
client: { clientSecret: result.clientSecret, publishableKey: config.publishableKey, stripeAccount: this.accountId, livemode: result.scope.livemode, contextId: result.scope.contextId } };
|
|
98
|
+
}
|
|
99
|
+
async processRefund(input) {
|
|
100
|
+
const original = await this.original(input.providerSessionId), client = await this.client();
|
|
101
|
+
const result = connectOperationResultSchema.parse(await client.request(CONNECT_API_PATHS.refunds, { operationId: input.operationId, originalPaymentId: original.id, amountMinor: input.amountCents }));
|
|
102
|
+
if (!isDeepStrictEqual(result.scope, this.scope) || result.paymentId !== original.id || result.operationId !== input.operationId || result.amountMinor !== input.amountCents)
|
|
103
|
+
throw new AppError('Refund response does not match', 'PAYMENT_SCOPE_INVALID', 409);
|
|
104
|
+
if (result.state === 'failed')
|
|
105
|
+
throw new AppError('Provider rejected this refund', 'REFUND_REJECTED', 400);
|
|
106
|
+
if (!result.providerObjectId || result.state !== 'succeeded')
|
|
107
|
+
throw new AppError('Refund outcome needs reconciliation', 'PAYMENT_OUTCOME_UNKNOWN', 503);
|
|
108
|
+
const status = result.financialStatus;
|
|
109
|
+
return { refundId: result.providerObjectId, amountCents: result.amountMinor, currency: result.currency,
|
|
110
|
+
status: status === 'succeeded' || status === 'failed' || status === 'canceled' || status === 'requires_action' ? status : 'pending', raw: {} };
|
|
111
|
+
}
|
|
112
|
+
async cancelSession(paymentIntentId) { await this.cancelAttempt((await this.original(paymentIntentId)).id); }
|
|
113
|
+
async cancelAttempt(attemptId) {
|
|
114
|
+
const result = connectOperationResultSchema.parse(await (await this.client()).request(connectPath(CONNECT_API_PATHS.cancel, attemptId), {}));
|
|
115
|
+
if (!isDeepStrictEqual(result.scope, this.scope))
|
|
116
|
+
throw new AppError('Cancellation scope does not match', 'PAYMENT_SCOPE_INVALID', 409);
|
|
117
|
+
if (result.financialStatus === 'succeeded')
|
|
118
|
+
throw new AppError('Payment captured before cancellation', 'PAYMENT_ALREADY_CAPTURED', 409);
|
|
119
|
+
if (result.financialStatus !== 'canceled')
|
|
120
|
+
throw new AppError('Payment cancellation needs recovery', 'PAYMENT_CANCEL_PENDING', 503);
|
|
121
|
+
}
|
|
122
|
+
async getCapture(paymentIntentId) {
|
|
123
|
+
const original = await this.original(paymentIntentId);
|
|
124
|
+
await (await this.client()).request(connectPath(CONNECT_API_PATHS.operation, original.id));
|
|
125
|
+
await this.sync();
|
|
126
|
+
const rows = await this.db.select().from(financialFacts).where(and(eq(financialFacts.originalBindingId, this.identity.connectionId), eq(financialFacts.paymentIntentId, paymentIntentId), eq(financialFacts.kind, 'capture')));
|
|
127
|
+
const fact = rows[0]?.fact;
|
|
128
|
+
if (!fact || fact.kind !== 'capture')
|
|
129
|
+
throw new AppError('Capture is not confirmed by signed facts', 'CAPTURE_UNCONFIRMED', 409);
|
|
130
|
+
return { provider: this.name, identity: this.identity, accountId: this.accountId, providerSessionId: paymentIntentId, providerPaymentId: paymentIntentId, amountCents: fact.amountMinor, currency: fact.currency, raw: {} };
|
|
131
|
+
}
|
|
132
|
+
async getRefunds(paymentIntentId) {
|
|
133
|
+
const original = await this.original(paymentIntentId);
|
|
134
|
+
await (await this.client()).request(connectPath(CONNECT_API_PATHS.operation, original.id));
|
|
135
|
+
await this.sync();
|
|
136
|
+
const rows = await this.db.select().from(financialFacts).where(and(eq(financialFacts.originalBindingId, this.identity.connectionId), eq(financialFacts.paymentIntentId, paymentIntentId), eq(financialFacts.kind, 'refund')));
|
|
137
|
+
return rows.flatMap(({ fact }) => fact.kind === 'refund' ? [{ provider: this.name, identity: this.identity, accountId: this.accountId, refundId: fact.objectId, providerSessionId: paymentIntentId, amountCents: fact.amountMinor, currency: fact.currency, status: fact.status, version: fact.factVersion, operationId: fact.operationId, raw: {} }] : []);
|
|
138
|
+
}
|
|
139
|
+
async getRefund(refundId) {
|
|
140
|
+
const [fact] = await this.db.select().from(financialFacts).where(and(eq(financialFacts.originalBindingId, this.identity.connectionId), eq(financialFacts.kind, 'refund'), eq(financialFacts.objectId, refundId)));
|
|
141
|
+
if (!fact)
|
|
142
|
+
throw new AppError('Signed refund fact is pending', 'PAYMENT_FACT_PENDING', 503);
|
|
143
|
+
const facts = await this.getRefunds(fact.paymentIntentId);
|
|
144
|
+
const found = facts.find((r) => r.refundId === refundId);
|
|
145
|
+
if (!found)
|
|
146
|
+
throw new AppError('Signed refund fact is pending', 'PAYMENT_FACT_PENDING', 503);
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** The registration for `stripe-connect:card`. */
|
|
151
|
+
export function connectRegistration(services) {
|
|
152
|
+
const db = services.ctx.db;
|
|
153
|
+
const registration = {
|
|
154
|
+
// USD needs at least 50 cents on a card, so a gift card leaves that much.
|
|
155
|
+
minimumChargeCents: { usd: 50 },
|
|
156
|
+
async publicConfiguration({ config }) {
|
|
157
|
+
// With the Hub off the method is unavailable. Recorded payments stay.
|
|
158
|
+
if ((await services.ctx.hub.status()).state === 'disabled')
|
|
159
|
+
return null;
|
|
160
|
+
const provider = await registration.factory({ config });
|
|
161
|
+
if (!provider)
|
|
162
|
+
return null;
|
|
163
|
+
const scope = connectScopeOf(provider.identity);
|
|
164
|
+
const enrollment = await services.enrollments.credentials(provider.identity.connectionId);
|
|
165
|
+
const publicConfig = connectConfigurationSchema.safeParse(await new ConnectHubClient(enrollment.scope.hubOrigin, enrollment.token).request(CONNECT_API_PATHS.configuration));
|
|
166
|
+
if (!publicConfig.success || !isDeepStrictEqual(publicConfig.data.scope, scope))
|
|
167
|
+
return null;
|
|
168
|
+
return { publishableKey: publicConfig.data.publishableKey, stripeAccount: provider.accountId, livemode: scope.livemode };
|
|
169
|
+
},
|
|
170
|
+
// Checkout uses the enrollment in the method's configuration. A recorded
|
|
171
|
+
// payment uses its original enrollment, or the active enrollment approved
|
|
172
|
+
// to recover it.
|
|
173
|
+
async factory({ config, identity }) {
|
|
174
|
+
let recipientId = identity?.connectionId ?? config.paymentBindingId;
|
|
175
|
+
if (!recipientId)
|
|
176
|
+
return null;
|
|
177
|
+
const [original] = await db.select().from(enrollments).where(eq(enrollments.id, recipientId));
|
|
178
|
+
if (!original || (identity && !isDeepStrictEqual(original.scope, connectScopeOf(identity))))
|
|
179
|
+
return null;
|
|
180
|
+
if (!identity && original.state !== 'active')
|
|
181
|
+
return null;
|
|
182
|
+
if (!['active', 'disabled'].includes(original.state) || !original.tokenBox) {
|
|
183
|
+
const candidates = await db.select().from(enrollments);
|
|
184
|
+
const replacement = candidates.find((c) => ['active', 'disabled'].includes(c.state) && !!c.tokenBox && c.recoveryBindingIds.includes(original.id) && c.scope.hubAccountId === original.scope.hubAccountId && c.scope.hubStoreId === original.scope.hubStoreId && c.scope.hubEnvironmentId === original.scope.hubEnvironmentId);
|
|
185
|
+
if (!replacement)
|
|
186
|
+
return null;
|
|
187
|
+
recipientId = replacement.id;
|
|
188
|
+
}
|
|
189
|
+
// Runtime validation and local revocation happen before any Hub network request.
|
|
190
|
+
await services.enrollments.credentials(recipientId);
|
|
191
|
+
return new ConnectPaymentProvider(services, identity ?? connectIdentity(original.id, original.scope), recipientId);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
return registration;
|
|
195
|
+
}
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@stratacommerce/extension-sdk';
|
|
2
|
+
import type { Enrollments } from './enrollments.js';
|
|
3
|
+
import type { Facts } from './facts.js';
|
|
4
|
+
export declare function registerHubRoutes(ctx: ExtensionContext, enrollments: Enrollments, facts: Facts): void;
|
|
5
|
+
export declare function registerAdminRoutes(ctx: ExtensionContext, enrollments: Enrollments, facts: Facts): void;
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CONNECT_PATHS } from '@stratacommerce/hub-protocol';
|
|
3
|
+
import { hubOff } from './identity.js';
|
|
4
|
+
const prefix = '/hub/v1/extensions/stripe-connect';
|
|
5
|
+
/** A CONNECT_PATHS entry relative to this extension's Hub route mount. */
|
|
6
|
+
const hubPath = (path) => { if (!path.startsWith(prefix))
|
|
7
|
+
throw new Error(`Connect path outside the extension: ${path}`); return path.slice(prefix.length); };
|
|
8
|
+
const enabledBody = z.object({ enabled: z.boolean() });
|
|
9
|
+
const noStore = (res) => res.header('Cache-Control', 'no-store');
|
|
10
|
+
// Hub-to-store calls carry the Hub token of the active link; core checks it
|
|
11
|
+
// before these handlers run. Fact deliveries are also signed.
|
|
12
|
+
export function registerHubRoutes(ctx, enrollments, facts) {
|
|
13
|
+
ctx.routes.addHubRoute('POST', hubPath(CONNECT_PATHS.challenge), async (req, res) => res.json(await enrollments.recordChallenge(req.link, req.body)));
|
|
14
|
+
ctx.routes.addHubRoute('GET', hubPath(CONNECT_PATHS.approval), async (req, res) => res.json(await enrollments.approval(req.link, req.params.bindingId)));
|
|
15
|
+
ctx.routes.addHubRoute('POST', hubPath(CONNECT_PATHS.enroll), async (req, res) => res.json(await enrollments.enroll(req.link, req.params.bindingId, req.body)), { bodyLimit: 1024 });
|
|
16
|
+
ctx.routes.addHubRoute('POST', hubPath(CONNECT_PATHS.keys), async (req, res) => res.json(await enrollments.refreshKeys(req.link, req.params.bindingId, req.body)));
|
|
17
|
+
// The signed facts schema allows 32 KiB of payload plus signature and envelope.
|
|
18
|
+
ctx.routes.addHubRoute('POST', hubPath(CONNECT_PATHS.facts), async (req, res) => {
|
|
19
|
+
const receipt = await facts.receive(req.body);
|
|
20
|
+
await facts.projectPending();
|
|
21
|
+
res.json(receipt);
|
|
22
|
+
}, { bodyLimit: 34 * 1024 });
|
|
23
|
+
}
|
|
24
|
+
// Order facts follow core's order read permission; payment settings are owner-only.
|
|
25
|
+
export function registerAdminRoutes(ctx, enrollments, facts) {
|
|
26
|
+
const owner = { minRole: 'owner' };
|
|
27
|
+
ctx.routes.addAdminRoute('GET', '/status', async (_req, res) => noStore(res).json({ hub: (await ctx.hub.status()).state, connections: await enrollments.status() }), owner);
|
|
28
|
+
ctx.routes.addAdminRoute('GET', '/financials', async (_req, res) => noStore(res).json(await facts.latest()), owner);
|
|
29
|
+
ctx.routes.addAdminRoute('GET', '/orders/:orderId/financials', async (req, res) => noStore(res).json(await facts.forOrder(z.string().uuid().parse(req.params.orderId))), { minRole: 'support' });
|
|
30
|
+
ctx.routes.addAdminRoute('POST', '/approvals/:id/approve', async (req, res) => noStore(res).json(await enrollments.approve(req.actor.id, req.params.id, req.body)), owner);
|
|
31
|
+
ctx.routes.addAdminRoute('POST', '/connections/:id/revoke', async (req, res) => noStore(res).json(await enrollments.revoke(req.params.id)), owner);
|
|
32
|
+
ctx.routes.addAdminRoute('POST', '/connections/:id/enabled', async (req, res) => noStore(res).json(await enrollments.setEnabled(req.params.id, enabledBody.parse(req.body).enabled)), owner);
|
|
33
|
+
ctx.routes.addAdminRoute('POST', '/disconnect', async (_req, res) => {
|
|
34
|
+
for (const row of await enrollments.status())
|
|
35
|
+
if (['active', 'disabled'].includes(row.state))
|
|
36
|
+
await enrollments.revoke(row.id);
|
|
37
|
+
await ctx.payments.setMethod('card', { enabled: false, config: {} });
|
|
38
|
+
noStore(res).json({ connected: false });
|
|
39
|
+
}, owner);
|
|
40
|
+
// Connect onboarding needs a usable store link whose API address the Hub has confirmed.
|
|
41
|
+
ctx.routes.addAdminRoute('GET', '/connect-url', async (_req, res) => {
|
|
42
|
+
const { state, link } = await ctx.hub.status();
|
|
43
|
+
noStore(res);
|
|
44
|
+
if (state === 'disabled')
|
|
45
|
+
return res.json({ available: false, reason: hubOff().message });
|
|
46
|
+
if (state === 'unusable')
|
|
47
|
+
return res.json({ available: false, reason: 'This store’s Hub link is unusable in this environment. Connect it to Strata Hub again.' });
|
|
48
|
+
if (state !== 'connected' || !link)
|
|
49
|
+
return res.json({ available: false, reason: 'Connect this store to Strata Hub first.' });
|
|
50
|
+
if (!link.addressConfirmed)
|
|
51
|
+
return res.json({ available: false, reason: 'Confirm the store’s API address with Strata Hub first. Payments need a public API address.' });
|
|
52
|
+
res.json({ available: true, url: `${link.hubOrigin}/accounts/${encodeURIComponent(link.account.id)}/sites/${link.store.id}/payments?environmentId=${link.environment.id}` });
|
|
53
|
+
}, owner);
|
|
54
|
+
}
|