@stratacommerce/stripe-connect 0.0.0-stage → 0.1.1
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 +173 -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 +65 -0
- package/dist/provider.js +227 -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/facts.d.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { type ExtensionContext } from '@stratacommerce/extension-sdk';
|
|
2
|
+
import { type ConnectFactReceipt } from '@stratacommerce/hub-protocol';
|
|
3
|
+
export declare function createFacts(ctx: ExtensionContext): {
|
|
4
|
+
projectPending(limit?: number): Promise<number>;
|
|
5
|
+
receive(input: unknown): Promise<ConnectFactReceipt>;
|
|
6
|
+
/** The latest facts, for the admin. */
|
|
7
|
+
latest(limit?: number): Promise<({
|
|
8
|
+
status: "succeeded";
|
|
9
|
+
currency: string;
|
|
10
|
+
kind: "capture";
|
|
11
|
+
attemptId: string;
|
|
12
|
+
orderReference: string;
|
|
13
|
+
amountMinor: number;
|
|
14
|
+
scope: {
|
|
15
|
+
livemode: boolean;
|
|
16
|
+
hubEnvironmentId: string;
|
|
17
|
+
generation: number;
|
|
18
|
+
linkId: string;
|
|
19
|
+
hubOrigin: string;
|
|
20
|
+
hubAccountId: string;
|
|
21
|
+
hubStoreId: string;
|
|
22
|
+
paymentBindingId: string;
|
|
23
|
+
platformId: string;
|
|
24
|
+
stripeAccountId: string;
|
|
25
|
+
contextId: string;
|
|
26
|
+
environmentRole: "development" | "staging" | "production";
|
|
27
|
+
};
|
|
28
|
+
paymentIntentId: string;
|
|
29
|
+
factVersion: number;
|
|
30
|
+
objectId: string;
|
|
31
|
+
chargeId?: string | undefined;
|
|
32
|
+
balance?: {
|
|
33
|
+
id: string;
|
|
34
|
+
currency: string;
|
|
35
|
+
amountMinor: number;
|
|
36
|
+
feeMinor: number;
|
|
37
|
+
netMinor: number;
|
|
38
|
+
} | null | undefined;
|
|
39
|
+
} | {
|
|
40
|
+
status: "unknown" | "pending" | "requires_action" | "succeeded" | "failed" | "canceled";
|
|
41
|
+
currency: string;
|
|
42
|
+
kind: "refund";
|
|
43
|
+
operationId: string | null;
|
|
44
|
+
attemptId: string;
|
|
45
|
+
orderReference: string;
|
|
46
|
+
amountMinor: number;
|
|
47
|
+
scope: {
|
|
48
|
+
livemode: boolean;
|
|
49
|
+
hubEnvironmentId: string;
|
|
50
|
+
generation: number;
|
|
51
|
+
linkId: string;
|
|
52
|
+
hubOrigin: string;
|
|
53
|
+
hubAccountId: string;
|
|
54
|
+
hubStoreId: string;
|
|
55
|
+
paymentBindingId: string;
|
|
56
|
+
platformId: string;
|
|
57
|
+
stripeAccountId: string;
|
|
58
|
+
contextId: string;
|
|
59
|
+
environmentRole: "development" | "staging" | "production";
|
|
60
|
+
};
|
|
61
|
+
paymentIntentId: string;
|
|
62
|
+
factVersion: number;
|
|
63
|
+
objectId: string;
|
|
64
|
+
} | {
|
|
65
|
+
status: string;
|
|
66
|
+
currency: string;
|
|
67
|
+
kind: "dispute";
|
|
68
|
+
attemptId: string;
|
|
69
|
+
orderReference: string;
|
|
70
|
+
amountMinor: number;
|
|
71
|
+
scope: {
|
|
72
|
+
livemode: boolean;
|
|
73
|
+
hubEnvironmentId: string;
|
|
74
|
+
generation: number;
|
|
75
|
+
linkId: string;
|
|
76
|
+
hubOrigin: string;
|
|
77
|
+
hubAccountId: string;
|
|
78
|
+
hubStoreId: string;
|
|
79
|
+
paymentBindingId: string;
|
|
80
|
+
platformId: string;
|
|
81
|
+
stripeAccountId: string;
|
|
82
|
+
contextId: string;
|
|
83
|
+
environmentRole: "development" | "staging" | "production";
|
|
84
|
+
};
|
|
85
|
+
paymentIntentId: string;
|
|
86
|
+
factVersion: number;
|
|
87
|
+
objectId: string;
|
|
88
|
+
lostPrincipalMinor: number;
|
|
89
|
+
heldPrincipalMinor: number;
|
|
90
|
+
deadline: number | null;
|
|
91
|
+
} | {
|
|
92
|
+
currency: string;
|
|
93
|
+
kind: "application_fee";
|
|
94
|
+
attemptId: string;
|
|
95
|
+
orderReference: string;
|
|
96
|
+
amountMinor: number;
|
|
97
|
+
scope: {
|
|
98
|
+
livemode: boolean;
|
|
99
|
+
hubEnvironmentId: string;
|
|
100
|
+
generation: number;
|
|
101
|
+
linkId: string;
|
|
102
|
+
hubOrigin: string;
|
|
103
|
+
hubAccountId: string;
|
|
104
|
+
hubStoreId: string;
|
|
105
|
+
paymentBindingId: string;
|
|
106
|
+
platformId: string;
|
|
107
|
+
stripeAccountId: string;
|
|
108
|
+
contextId: string;
|
|
109
|
+
environmentRole: "development" | "staging" | "production";
|
|
110
|
+
};
|
|
111
|
+
paymentIntentId: string;
|
|
112
|
+
factVersion: number;
|
|
113
|
+
objectId: string;
|
|
114
|
+
refundedMinor: number;
|
|
115
|
+
balanceTransactionId: string | null;
|
|
116
|
+
} | {
|
|
117
|
+
currency: string;
|
|
118
|
+
kind: "fee_refund";
|
|
119
|
+
attemptId: string;
|
|
120
|
+
orderReference: string;
|
|
121
|
+
amountMinor: number;
|
|
122
|
+
scope: {
|
|
123
|
+
livemode: boolean;
|
|
124
|
+
hubEnvironmentId: string;
|
|
125
|
+
generation: number;
|
|
126
|
+
linkId: string;
|
|
127
|
+
hubOrigin: string;
|
|
128
|
+
hubAccountId: string;
|
|
129
|
+
hubStoreId: string;
|
|
130
|
+
paymentBindingId: string;
|
|
131
|
+
platformId: string;
|
|
132
|
+
stripeAccountId: string;
|
|
133
|
+
contextId: string;
|
|
134
|
+
environmentRole: "development" | "staging" | "production";
|
|
135
|
+
};
|
|
136
|
+
paymentIntentId: string;
|
|
137
|
+
factVersion: number;
|
|
138
|
+
objectId: string;
|
|
139
|
+
balanceTransactionId: string | null;
|
|
140
|
+
applicationFeeId: string;
|
|
141
|
+
})[]>;
|
|
142
|
+
/** Facts for one order, newest first: fees, disputes and refunds. */
|
|
143
|
+
forOrder(orderId: string): Promise<({
|
|
144
|
+
status: "succeeded";
|
|
145
|
+
currency: string;
|
|
146
|
+
kind: "capture";
|
|
147
|
+
attemptId: string;
|
|
148
|
+
orderReference: string;
|
|
149
|
+
amountMinor: number;
|
|
150
|
+
scope: {
|
|
151
|
+
livemode: boolean;
|
|
152
|
+
hubEnvironmentId: string;
|
|
153
|
+
generation: number;
|
|
154
|
+
linkId: string;
|
|
155
|
+
hubOrigin: string;
|
|
156
|
+
hubAccountId: string;
|
|
157
|
+
hubStoreId: string;
|
|
158
|
+
paymentBindingId: string;
|
|
159
|
+
platformId: string;
|
|
160
|
+
stripeAccountId: string;
|
|
161
|
+
contextId: string;
|
|
162
|
+
environmentRole: "development" | "staging" | "production";
|
|
163
|
+
};
|
|
164
|
+
paymentIntentId: string;
|
|
165
|
+
factVersion: number;
|
|
166
|
+
objectId: string;
|
|
167
|
+
chargeId?: string | undefined;
|
|
168
|
+
balance?: {
|
|
169
|
+
id: string;
|
|
170
|
+
currency: string;
|
|
171
|
+
amountMinor: number;
|
|
172
|
+
feeMinor: number;
|
|
173
|
+
netMinor: number;
|
|
174
|
+
} | null | undefined;
|
|
175
|
+
} | {
|
|
176
|
+
status: "unknown" | "pending" | "requires_action" | "succeeded" | "failed" | "canceled";
|
|
177
|
+
currency: string;
|
|
178
|
+
kind: "refund";
|
|
179
|
+
operationId: string | null;
|
|
180
|
+
attemptId: string;
|
|
181
|
+
orderReference: string;
|
|
182
|
+
amountMinor: number;
|
|
183
|
+
scope: {
|
|
184
|
+
livemode: boolean;
|
|
185
|
+
hubEnvironmentId: string;
|
|
186
|
+
generation: number;
|
|
187
|
+
linkId: string;
|
|
188
|
+
hubOrigin: string;
|
|
189
|
+
hubAccountId: string;
|
|
190
|
+
hubStoreId: string;
|
|
191
|
+
paymentBindingId: string;
|
|
192
|
+
platformId: string;
|
|
193
|
+
stripeAccountId: string;
|
|
194
|
+
contextId: string;
|
|
195
|
+
environmentRole: "development" | "staging" | "production";
|
|
196
|
+
};
|
|
197
|
+
paymentIntentId: string;
|
|
198
|
+
factVersion: number;
|
|
199
|
+
objectId: string;
|
|
200
|
+
} | {
|
|
201
|
+
status: string;
|
|
202
|
+
currency: string;
|
|
203
|
+
kind: "dispute";
|
|
204
|
+
attemptId: string;
|
|
205
|
+
orderReference: string;
|
|
206
|
+
amountMinor: number;
|
|
207
|
+
scope: {
|
|
208
|
+
livemode: boolean;
|
|
209
|
+
hubEnvironmentId: string;
|
|
210
|
+
generation: number;
|
|
211
|
+
linkId: string;
|
|
212
|
+
hubOrigin: string;
|
|
213
|
+
hubAccountId: string;
|
|
214
|
+
hubStoreId: string;
|
|
215
|
+
paymentBindingId: string;
|
|
216
|
+
platformId: string;
|
|
217
|
+
stripeAccountId: string;
|
|
218
|
+
contextId: string;
|
|
219
|
+
environmentRole: "development" | "staging" | "production";
|
|
220
|
+
};
|
|
221
|
+
paymentIntentId: string;
|
|
222
|
+
factVersion: number;
|
|
223
|
+
objectId: string;
|
|
224
|
+
lostPrincipalMinor: number;
|
|
225
|
+
heldPrincipalMinor: number;
|
|
226
|
+
deadline: number | null;
|
|
227
|
+
} | {
|
|
228
|
+
currency: string;
|
|
229
|
+
kind: "application_fee";
|
|
230
|
+
attemptId: string;
|
|
231
|
+
orderReference: string;
|
|
232
|
+
amountMinor: number;
|
|
233
|
+
scope: {
|
|
234
|
+
livemode: boolean;
|
|
235
|
+
hubEnvironmentId: string;
|
|
236
|
+
generation: number;
|
|
237
|
+
linkId: string;
|
|
238
|
+
hubOrigin: string;
|
|
239
|
+
hubAccountId: string;
|
|
240
|
+
hubStoreId: string;
|
|
241
|
+
paymentBindingId: string;
|
|
242
|
+
platformId: string;
|
|
243
|
+
stripeAccountId: string;
|
|
244
|
+
contextId: string;
|
|
245
|
+
environmentRole: "development" | "staging" | "production";
|
|
246
|
+
};
|
|
247
|
+
paymentIntentId: string;
|
|
248
|
+
factVersion: number;
|
|
249
|
+
objectId: string;
|
|
250
|
+
refundedMinor: number;
|
|
251
|
+
balanceTransactionId: string | null;
|
|
252
|
+
} | {
|
|
253
|
+
currency: string;
|
|
254
|
+
kind: "fee_refund";
|
|
255
|
+
attemptId: string;
|
|
256
|
+
orderReference: string;
|
|
257
|
+
amountMinor: number;
|
|
258
|
+
scope: {
|
|
259
|
+
livemode: boolean;
|
|
260
|
+
hubEnvironmentId: string;
|
|
261
|
+
generation: number;
|
|
262
|
+
linkId: string;
|
|
263
|
+
hubOrigin: string;
|
|
264
|
+
hubAccountId: string;
|
|
265
|
+
hubStoreId: string;
|
|
266
|
+
paymentBindingId: string;
|
|
267
|
+
platformId: string;
|
|
268
|
+
stripeAccountId: string;
|
|
269
|
+
contextId: string;
|
|
270
|
+
environmentRole: "development" | "staging" | "production";
|
|
271
|
+
};
|
|
272
|
+
paymentIntentId: string;
|
|
273
|
+
factVersion: number;
|
|
274
|
+
objectId: string;
|
|
275
|
+
balanceTransactionId: string | null;
|
|
276
|
+
applicationFeeId: string;
|
|
277
|
+
})[]>;
|
|
278
|
+
};
|
|
279
|
+
export type Facts = ReturnType<typeof createFacts>;
|
package/dist/facts.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { and, desc, eq, lte, sql } from 'drizzle-orm';
|
|
4
|
+
import { AppError } from '@stratacommerce/extension-sdk';
|
|
5
|
+
import { connectDeliverySchema, connectSignedDeliverySchema } from '@stratacommerce/hub-protocol';
|
|
6
|
+
import { assertConnectLink, CONNECT_PROVIDER, connectScopeOf } from './identity.js';
|
|
7
|
+
import { enrollments, financialFacts, receipts } from './schema.js';
|
|
8
|
+
// Signed payment facts from the Hub. The extension accepts them only while
|
|
9
|
+
// the recipient enrollment's store link is the active link and usable here,
|
|
10
|
+
// and records each receipt under a share lock on that link, so a revocation
|
|
11
|
+
// can't interleave.
|
|
12
|
+
export function createFacts(ctx) {
|
|
13
|
+
const db = ctx.db;
|
|
14
|
+
return {
|
|
15
|
+
async projectPending(limit = 100) {
|
|
16
|
+
const pending = await db.select().from(receipts).where(and(eq(receipts.state, 'pending'), lte(receipts.nextAttemptAt, new Date())))
|
|
17
|
+
.orderBy(sql `CASE WHEN ${receipts.errorCode} IS NOT NULL THEN 2 WHEN ${receipts.fact}->>'kind' = 'capture' THEN 0 ELSE 1 END`, receipts.nextAttemptAt, receipts.createdAt, receipts.deliveryId).limit(limit);
|
|
18
|
+
let projected = 0;
|
|
19
|
+
for (const receipt of pending) {
|
|
20
|
+
try {
|
|
21
|
+
const fact = receipt.fact;
|
|
22
|
+
const [latest] = await db.select().from(financialFacts).where(and(eq(financialFacts.originalBindingId, receipt.originalBindingId), eq(financialFacts.kind, fact.kind), eq(financialFacts.objectId, fact.objectId)));
|
|
23
|
+
if (latest && latest.version > fact.factVersion) {
|
|
24
|
+
await db.update(receipts).set({ state: 'projected' }).where(eq(receipts.deliveryId, receipt.deliveryId));
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const attempt = await ctx.payments.attempt(fact.attemptId);
|
|
28
|
+
if (!attempt || attempt.orderId !== fact.orderReference || attempt.provider !== CONNECT_PROVIDER || !isDeepStrictEqual(connectScopeOf(attempt.identity), fact.scope) || attempt.currency !== fact.currency && fact.kind !== 'application_fee' && fact.kind !== 'fee_refund' ||
|
|
29
|
+
attempt.providerSessionId && attempt.providerSessionId !== fact.paymentIntentId || fact.kind === 'capture' && attempt.amountCents !== fact.amountMinor)
|
|
30
|
+
throw new AppError('Payment fact does not match the original checkout', 'PAYMENT_FACT_IDENTITY_MISMATCH', 409);
|
|
31
|
+
if (fact.kind === 'capture') {
|
|
32
|
+
await ctx.payments.recordCapture({ attemptId: attempt.id, identity: attempt.identity, providerSessionId: fact.paymentIntentId, providerPaymentId: fact.paymentIntentId, amountCents: fact.amountMinor, currency: fact.currency });
|
|
33
|
+
}
|
|
34
|
+
else if (fact.kind === 'refund') {
|
|
35
|
+
await ctx.payments.recordRefund({ provider: CONNECT_PROVIDER, identity: attempt.identity, version: fact.factVersion, accountId: fact.scope.stripeAccountId, refundId: fact.objectId,
|
|
36
|
+
providerSessionId: fact.paymentIntentId, amountCents: fact.amountMinor, currency: fact.currency, status: fact.status, operationId: fact.operationId, raw: {} });
|
|
37
|
+
}
|
|
38
|
+
// Disputes and fee settlements remain separate financial facts; they do not change orders.
|
|
39
|
+
await db.update(receipts).set({ state: 'projected', errorCode: null }).where(eq(receipts.deliveryId, receipt.deliveryId));
|
|
40
|
+
projected++;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
await db.update(receipts).set({ errorCode: error instanceof AppError ? error.code : 'PAYMENT_PROJECTION_FAILED', nextAttemptAt: new Date(Date.now() + 60000) }).where(eq(receipts.deliveryId, receipt.deliveryId));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return projected;
|
|
47
|
+
},
|
|
48
|
+
async receive(input) {
|
|
49
|
+
const envelope = connectSignedDeliverySchema.parse(input), bytes = Buffer.from(envelope.payload, 'base64url');
|
|
50
|
+
let decoded;
|
|
51
|
+
try {
|
|
52
|
+
decoded = JSON.parse(bytes.toString('utf8'));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new AppError('Invalid payment delivery', 'PAYMENT_DELIVERY_INVALID', 400);
|
|
56
|
+
}
|
|
57
|
+
const delivery = connectDeliverySchema.parse(decoded);
|
|
58
|
+
const [recipient] = await db.select().from(enrollments).where(eq(enrollments.id, delivery.recipient.paymentBindingId));
|
|
59
|
+
if (!recipient || !['active', 'disabled'].includes(recipient.state) || !isDeepStrictEqual(recipient.scope, delivery.recipient) || delivery.issuer !== recipient.scope.hubOrigin)
|
|
60
|
+
throw new AppError('Payment recipient does not match this connection', 'PAYMENT_DELIVERY_SCOPE_INVALID', 403);
|
|
61
|
+
const link = await ctx.hub.link();
|
|
62
|
+
assertConnectLink(recipient.scope, link, ctx.environment);
|
|
63
|
+
if (recipient.linkId !== link.linkId)
|
|
64
|
+
throw new AppError('Reconnect the recipient to Hub', 'PAYMENT_DELIVERY_SCOPE_INVALID', 403);
|
|
65
|
+
const pem = recipient.publicKeys[delivery.keyId];
|
|
66
|
+
if (!pem)
|
|
67
|
+
throw new AppError('Refresh the trusted Hub delivery keys', 'PAYMENT_DELIVERY_KEY_UNKNOWN', 503);
|
|
68
|
+
if (!verify(null, bytes, createPublicKey(pem), Buffer.from(envelope.signature, 'base64url')))
|
|
69
|
+
throw new AppError('Invalid payment signature', 'PAYMENT_DELIVERY_SIGNATURE_INVALID', 403);
|
|
70
|
+
if (!isDeepStrictEqual(delivery.fact.scope, delivery.original) || (delivery.original.paymentBindingId !== recipient.id && !recipient.recoveryBindingIds.includes(delivery.original.paymentBindingId)))
|
|
71
|
+
throw new AppError('Historical payment authority is required', 'PAYMENT_DELIVERY_SCOPE_INVALID', 403);
|
|
72
|
+
const [original] = await db.select().from(enrollments).where(eq(enrollments.id, delivery.original.paymentBindingId));
|
|
73
|
+
if (!original || !isDeepStrictEqual(original.scope, delivery.original))
|
|
74
|
+
throw new AppError('Original payment scope does not match', 'PAYMENT_DELIVERY_SCOPE_INVALID', 403);
|
|
75
|
+
const age = Date.now() - Date.parse(delivery.issuedAt);
|
|
76
|
+
if (age > 300000 || age < -60000)
|
|
77
|
+
throw new AppError('Request a fresh signed delivery', 'PAYMENT_DELIVERY_EXPIRED', 400);
|
|
78
|
+
// A revocation waits for this receipt, or this receipt sees it: core
|
|
79
|
+
// fails the transaction when the link is no longer active.
|
|
80
|
+
return ctx.hub.transaction(recipient.linkId, async (tx) => {
|
|
81
|
+
const [current] = await tx.select().from(enrollments).where(eq(enrollments.id, recipient.id)).for('update');
|
|
82
|
+
if (!current || !['active', 'disabled'].includes(current.state) || current.linkId !== recipient.linkId || !isDeepStrictEqual(current.scope, delivery.recipient) || current.publicKeys[delivery.keyId] !== pem ||
|
|
83
|
+
delivery.original.paymentBindingId !== current.id && !current.recoveryBindingIds.includes(delivery.original.paymentBindingId))
|
|
84
|
+
throw new AppError('Payment authorization changed before receipt', 'PAYMENT_DELIVERY_SCOPE_INVALID', 403);
|
|
85
|
+
if (Date.now() - Date.parse(delivery.issuedAt) > 300000)
|
|
86
|
+
throw new AppError('Request a fresh signed delivery', 'PAYMENT_DELIVERY_EXPIRED', 400);
|
|
87
|
+
const [existing] = await tx.select().from(receipts).where(eq(receipts.deliveryId, delivery.deliveryId));
|
|
88
|
+
if (existing) {
|
|
89
|
+
if (existing.originalBindingId !== original.id || existing.recipientBindingId !== recipient.id || !isDeepStrictEqual(existing.fact, delivery.fact))
|
|
90
|
+
throw new AppError('Delivery ID already has different contents', 'PAYMENT_DELIVERY_CONFLICT', 409);
|
|
91
|
+
return { deliveryId: delivery.deliveryId };
|
|
92
|
+
}
|
|
93
|
+
const [previous] = await tx.select().from(financialFacts).where(and(eq(financialFacts.originalBindingId, original.id), eq(financialFacts.kind, delivery.fact.kind), eq(financialFacts.objectId, delivery.fact.objectId))).for('update');
|
|
94
|
+
if (previous?.version === delivery.fact.factVersion && !isDeepStrictEqual(previous.fact, delivery.fact))
|
|
95
|
+
throw new AppError('Payment fact version conflicts', 'PAYMENT_DELIVERY_CONFLICT', 409);
|
|
96
|
+
const stale = !!previous && previous.version >= delivery.fact.factVersion;
|
|
97
|
+
if (!stale) {
|
|
98
|
+
if (previous)
|
|
99
|
+
await tx.update(financialFacts).set({ version: delivery.fact.factVersion, fact: delivery.fact }).where(eq(financialFacts.id, previous.id));
|
|
100
|
+
else
|
|
101
|
+
await tx.insert(financialFacts).values({ originalBindingId: original.id, paymentIntentId: delivery.fact.paymentIntentId, kind: delivery.fact.kind, objectId: delivery.fact.objectId, version: delivery.fact.factVersion, fact: delivery.fact });
|
|
102
|
+
}
|
|
103
|
+
await tx.insert(receipts).values({ deliveryId: delivery.deliveryId, originalBindingId: original.id, recipientBindingId: recipient.id, fact: delivery.fact, state: stale ? 'projected' : 'pending' });
|
|
104
|
+
return { deliveryId: delivery.deliveryId };
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
/** The latest facts, for the admin. */
|
|
108
|
+
async latest(limit = 100) {
|
|
109
|
+
return (await db.select({ fact: financialFacts.fact }).from(financialFacts).orderBy(desc(financialFacts.createdAt)).limit(limit)).map((row) => row.fact);
|
|
110
|
+
},
|
|
111
|
+
/** Facts for one order, newest first: fees, disputes and refunds. */
|
|
112
|
+
async forOrder(orderId) {
|
|
113
|
+
return (await db.select({ fact: financialFacts.fact }).from(financialFacts).where(sql `${financialFacts.fact}->>'orderReference' = ${orderId}`).orderBy(desc(financialFacts.createdAt))).map((row) => row.fact);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AppError, type HubLink, type PaymentIdentity, type StoreEnvironment } from '@stratacommerce/extension-sdk';
|
|
2
|
+
import { type ConnectScope } from '@stratacommerce/hub-protocol';
|
|
3
|
+
/** Connect's name in the payment provider registry. */
|
|
4
|
+
export declare const CONNECT_PROVIDER = "stripe-connect:card";
|
|
5
|
+
/**
|
|
6
|
+
* A Connect scope belongs to the store link it was enrolled through
|
|
7
|
+
* (docs/hub-store-link.md). Payment work runs only while that link is this
|
|
8
|
+
* environment's active, usable link, so a copied database refuses it. Live
|
|
9
|
+
* mode also needs STRATA_ENVIRONMENT=production.
|
|
10
|
+
*/
|
|
11
|
+
export declare function assertConnectLink(scope: ConnectScope, link: HubLink | null, environment: StoreEnvironment): asserts link is HubLink;
|
|
12
|
+
/** Strata Hub is off for this store: STRATA_HUB_KEY isn't set on the API. */
|
|
13
|
+
export declare function hubOff(): AppError;
|
|
14
|
+
/** The payment identity of a Connect enrollment: its scope is the provider's details. */
|
|
15
|
+
export declare function connectIdentity(enrollmentId: string, scope: ConnectScope): PaymentIdentity;
|
|
16
|
+
/** The Connect scope inside a recorded identity, or an error when it isn't a Connect identity. */
|
|
17
|
+
export declare function connectScopeOf(identity: PaymentIdentity): ConnectScope;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AppError } from '@stratacommerce/extension-sdk';
|
|
2
|
+
import { connectScopeSchema } from '@stratacommerce/hub-protocol';
|
|
3
|
+
/** Connect's name in the payment provider registry. */
|
|
4
|
+
export const CONNECT_PROVIDER = 'stripe-connect:card';
|
|
5
|
+
/**
|
|
6
|
+
* A Connect scope belongs to the store link it was enrolled through
|
|
7
|
+
* (docs/hub-store-link.md). Payment work runs only while that link is this
|
|
8
|
+
* environment's active, usable link, so a copied database refuses it. Live
|
|
9
|
+
* mode also needs STRATA_ENVIRONMENT=production.
|
|
10
|
+
*/
|
|
11
|
+
export function assertConnectLink(scope, link, environment) {
|
|
12
|
+
if (!link)
|
|
13
|
+
throw new AppError('This store’s Hub link is missing or unusable here. Connect this environment to Strata Hub, then reconnect payments.', 'PAYMENT_LINK_UNUSABLE', 409);
|
|
14
|
+
if (scope.linkId !== link.linkId || scope.hubOrigin !== link.hubOrigin || scope.hubAccountId !== link.account.id ||
|
|
15
|
+
scope.hubStoreId !== link.store.id || scope.hubEnvironmentId !== link.environment.id)
|
|
16
|
+
throw new AppError('This payment connection belongs to another Hub link. Reconnect payments in Strata Hub.', 'PAYMENT_ENVIRONMENT_MISMATCH', 409);
|
|
17
|
+
if (scope.livemode && environment !== 'production')
|
|
18
|
+
throw new AppError('Live payments need STRATA_ENVIRONMENT=production.', 'PAYMENT_ENVIRONMENT_MISMATCH', 409);
|
|
19
|
+
}
|
|
20
|
+
/** Strata Hub is off for this store: STRATA_HUB_KEY isn't set on the API. */
|
|
21
|
+
export function hubOff() {
|
|
22
|
+
return new AppError('Strata Hub is off for this store, so Stripe Connect payments are unavailable. Set STRATA_HUB_KEY on the API to turn it back on.', 'HUB_DISABLED', 409);
|
|
23
|
+
}
|
|
24
|
+
/** The payment identity of a Connect enrollment: its scope is the provider's details. */
|
|
25
|
+
export function connectIdentity(enrollmentId, scope) {
|
|
26
|
+
return { provider: CONNECT_PROVIDER, accountId: scope.stripeAccountId, connectionId: enrollmentId, livemode: scope.livemode, details: scope };
|
|
27
|
+
}
|
|
28
|
+
/** The Connect scope inside a recorded identity, or an error when it isn't a Connect identity. */
|
|
29
|
+
export function connectScopeOf(identity) {
|
|
30
|
+
const scope = connectScopeSchema.safeParse(identity.details);
|
|
31
|
+
if (identity.provider !== CONNECT_PROVIDER || !scope.success || identity.accountId !== scope.data.stripeAccountId || identity.connectionId !== scope.data.paymentBindingId || identity.livemode !== scope.data.livemode)
|
|
32
|
+
throw new AppError('Original Connect identity is required', 'PAYMENT_SCOPE_INVALID', 409);
|
|
33
|
+
return scope.data;
|
|
34
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineExtension } from '@stratacommerce/extension-sdk';
|
|
2
|
+
import { createEnrollments } from './enrollments.js';
|
|
3
|
+
import { createFacts } from './facts.js';
|
|
4
|
+
import { migrations } from './migrations.js';
|
|
5
|
+
import { connectRegistration } from './provider.js';
|
|
6
|
+
import { registerAdminRoutes, registerHubRoutes } from './routes.js';
|
|
7
|
+
export { CONNECT_PROVIDER, connectIdentity, connectScopeOf } from './identity.js';
|
|
8
|
+
export { ConnectHubClient, ConnectPaymentProvider } from './provider.js';
|
|
9
|
+
// Card payments through Strata Hub's Stripe Connect platform
|
|
10
|
+
// (docs/stripe-connect-operations.md). The Hub calls Stripe; this extension
|
|
11
|
+
// enrolls the store, takes checkouts through the Hub and records the Hub's
|
|
12
|
+
// signed payment facts.
|
|
13
|
+
export default defineExtension({
|
|
14
|
+
manifest: {
|
|
15
|
+
id: 'stripe-connect',
|
|
16
|
+
name: 'Stripe Connect',
|
|
17
|
+
version: '0.1.1',
|
|
18
|
+
apiVersion: '^1.0.0',
|
|
19
|
+
category: 'payment',
|
|
20
|
+
description: 'Card payments through Strata Hub’s Stripe Connect platform. Needs a store linked to Strata Hub.',
|
|
21
|
+
},
|
|
22
|
+
migrations,
|
|
23
|
+
register(ctx) {
|
|
24
|
+
const enrollments = createEnrollments(ctx), facts = createFacts(ctx);
|
|
25
|
+
ctx.payments.register('card', connectRegistration({ ctx, enrollments, facts }));
|
|
26
|
+
registerHubRoutes(ctx, enrollments, facts);
|
|
27
|
+
registerAdminRoutes(ctx, enrollments, facts);
|
|
28
|
+
ctx.jobs.every('project-facts', 60_000, async () => { await facts.projectPending(); });
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const migrations = [
|
|
2
|
+
{
|
|
3
|
+
"name": "0000_init",
|
|
4
|
+
"sql": "CREATE SCHEMA IF NOT EXISTS \"ext_stripe_connect\";\n--> statement-breakpoint\nCREATE TABLE \"ext_stripe_connect\".\"enrollments\" (\n\t\"id\" uuid PRIMARY KEY NOT NULL,\n\t\"link_id\" uuid NOT NULL,\n\t\"scope\" jsonb NOT NULL,\n\t\"policy\" jsonb NOT NULL,\n\t\"state\" text NOT NULL,\n\t\"nonce_hash\" text NOT NULL,\n\t\"expires_at\" timestamp with time zone NOT NULL,\n\t\"token_box\" text,\n\t\"public_keys\" jsonb DEFAULT '{}'::jsonb NOT NULL,\n\t\"recovery_binding_ids\" jsonb DEFAULT '[]'::jsonb NOT NULL,\n\t\"approved_by\" uuid,\n\t\"approved_at\" timestamp with time zone,\n\t\"created_at\" timestamp with time zone DEFAULT now() NOT NULL\n);\n--> statement-breakpoint\nCREATE TABLE \"ext_stripe_connect\".\"financial_facts\" (\n\t\"id\" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,\n\t\"original_binding_id\" uuid NOT NULL,\n\t\"payment_intent_id\" text NOT NULL,\n\t\"kind\" text NOT NULL,\n\t\"object_id\" text NOT NULL,\n\t\"version\" integer NOT NULL,\n\t\"fact\" jsonb NOT NULL,\n\t\"created_at\" timestamp with time zone DEFAULT now() NOT NULL\n);\n--> statement-breakpoint\nCREATE TABLE \"ext_stripe_connect\".\"receipts\" (\n\t\"delivery_id\" uuid PRIMARY KEY NOT NULL,\n\t\"original_binding_id\" uuid NOT NULL,\n\t\"recipient_binding_id\" uuid NOT NULL,\n\t\"fact\" jsonb NOT NULL,\n\t\"state\" text DEFAULT 'pending' NOT NULL,\n\t\"error_code\" text,\n\t\"next_attempt_at\" timestamp with time zone DEFAULT now() NOT NULL,\n\t\"created_at\" timestamp with time zone DEFAULT now() NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX \"financial_fact_identity\" ON \"ext_stripe_connect\".\"financial_facts\" USING btree (\"original_binding_id\",\"kind\",\"object_id\");--> statement-breakpoint\nCREATE INDEX \"receipts_pending\" ON \"ext_stripe_connect\".\"receipts\" USING btree (\"state\",\"next_attempt_at\");"
|
|
5
|
+
}
|
|
6
|
+
];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type CreateSessionInput, type ExtensionContext, type PaymentIdentity, type PaymentProvider, type PaymentProviderRegistration, type ProviderRefund, type RefundResult } from '@stratacommerce/extension-sdk';
|
|
2
|
+
import type { Enrollments } from './enrollments.js';
|
|
3
|
+
import type { Facts } from './facts.js';
|
|
4
|
+
export declare class ConnectHubClient {
|
|
5
|
+
readonly origin: string;
|
|
6
|
+
private token;
|
|
7
|
+
private transport;
|
|
8
|
+
private revoked?;
|
|
9
|
+
/** `revoked` runs when the Hub no longer knows the token, as after a disconnect in the Hub. */
|
|
10
|
+
constructor(origin: string, token: string, transport?: typeof fetch, revoked?: (() => Promise<unknown>) | undefined);
|
|
11
|
+
request(path: string, body?: unknown): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface ConnectServices {
|
|
14
|
+
ctx: ExtensionContext;
|
|
15
|
+
enrollments: Enrollments;
|
|
16
|
+
facts: Facts;
|
|
17
|
+
}
|
|
18
|
+
export declare class ConnectPaymentProvider implements PaymentProvider {
|
|
19
|
+
private services;
|
|
20
|
+
readonly identity: PaymentIdentity;
|
|
21
|
+
private recipientId;
|
|
22
|
+
private makeClient?;
|
|
23
|
+
readonly name = "stripe-connect:card";
|
|
24
|
+
readonly accountId: string;
|
|
25
|
+
private readonly scope;
|
|
26
|
+
constructor(services: ConnectServices, identity: PaymentIdentity, recipientId: string, makeClient?: (() => Promise<ConnectHubClient>) | undefined);
|
|
27
|
+
private get db();
|
|
28
|
+
private client;
|
|
29
|
+
private original;
|
|
30
|
+
private sync;
|
|
31
|
+
createSession(input: CreateSessionInput): Promise<{
|
|
32
|
+
sessionId: string;
|
|
33
|
+
accountId: string;
|
|
34
|
+
client: {
|
|
35
|
+
clientSecret: string;
|
|
36
|
+
publishableKey: string;
|
|
37
|
+
stripeAccount: string;
|
|
38
|
+
livemode: boolean;
|
|
39
|
+
contextId: string;
|
|
40
|
+
};
|
|
41
|
+
}>;
|
|
42
|
+
processRefund(input: Parameters<PaymentProvider['processRefund']>[0]): Promise<RefundResult>;
|
|
43
|
+
cancelSession(paymentIntentId: string): Promise<void>;
|
|
44
|
+
cancelAttempt(attemptId: string): Promise<void>;
|
|
45
|
+
getCapture(paymentIntentId: string): Promise<{
|
|
46
|
+
provider: string;
|
|
47
|
+
identity: {
|
|
48
|
+
provider: string;
|
|
49
|
+
accountId: string | null;
|
|
50
|
+
connectionId: string;
|
|
51
|
+
livemode: boolean;
|
|
52
|
+
details: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
accountId: string;
|
|
55
|
+
providerSessionId: string;
|
|
56
|
+
providerPaymentId: string;
|
|
57
|
+
amountCents: number;
|
|
58
|
+
currency: string;
|
|
59
|
+
raw: {};
|
|
60
|
+
}>;
|
|
61
|
+
getRefunds(paymentIntentId: string): Promise<ProviderRefund[]>;
|
|
62
|
+
getRefund(refundId: string): Promise<ProviderRefund>;
|
|
63
|
+
}
|
|
64
|
+
/** The registration for `stripe-connect:card`. */
|
|
65
|
+
export declare function connectRegistration(services: ConnectServices): PaymentProviderRegistration;
|