@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StrataCommerce Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @stratacommerce/stripe-connect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Card payments through Strata Hub's Stripe Connect platform. The store never holds Stripe keys: the Hub calls Stripe, and the store records the Hub's signed payment facts. Needs a store linked to Strata Hub.
|
|
4
|
+
|
|
5
|
+
- `.` is the API module. Build it into the API and admin images with `STRATA_EXTENSIONS=@stratacommerce/stripe-connect`, or `@stratacommerce/stripe-connect@<version>` to install a published version.
|
|
6
|
+
- `./admin` adds the payments settings panel and order fee and dispute details.
|
|
7
|
+
- `./storefront` is the checkout component for a site's `src/payments.tsx`. Generated sites already depend on it.
|
|
8
|
+
|
|
9
|
+
`@stratacommerce/i18n`, `drizzle-orm` and `react` are optional peers: each host provides the ones its entries use, and the extension shares the host's i18n context. Each core release also publishes `stratacommerce-api-stripe-connect` and `stratacommerce-admin-stripe-connect` images with this extension built in.
|
|
10
|
+
|
|
11
|
+
In this repository, operations are in `docs/stripe-connect-operations.md`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useT } from '@stratacommerce/i18n';
|
|
4
|
+
import { NAMESPACE } from './messages.js';
|
|
5
|
+
// Signed payment facts from the Hub, as stored by the extension.
|
|
6
|
+
export function FactList({ facts, locale }) {
|
|
7
|
+
const t = useT(NAMESPACE);
|
|
8
|
+
return _jsx("ul", { className: "space-y-3", children: facts.map((fact) => _jsxs("li", { className: "text-sm break-words", children: [_jsx("strong", { children: t(`factKinds.${fact.kind}`) }), ": ", fact.amountMinor, " ", fact.currency.toUpperCase(), " ", t('minorUnits'), " ", 'status' in fact ? fact.status : '', _jsx("br", {}), _jsx("span", { className: "text-muted-foreground", children: fact.objectId }), fact.kind === 'dispute' && _jsxs(_Fragment, { children: [_jsx("br", {}), fact.deadline !== null && _jsxs(_Fragment, { children: [_jsxs("span", { children: [t('evidenceDue'), ": ", _jsx("time", { dateTime: new Date(fact.deadline * 1000).toISOString(), children: new Date(fact.deadline * 1000).toLocaleString(locale, { timeZoneName: 'short' }) })] }), _jsx("br", {})] }), _jsx("a", { className: "underline", href: `https://dashboard.stripe.com/${fact.scope.livemode ? '' : 'test/'}disputes/${encodeURIComponent(fact.objectId)}`, target: "_blank", rel: "noreferrer", children: t('manageDispute') })] })] }, `${fact.kind}:${fact.objectId}`)) });
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { defineAdminExtension } from '@stratacommerce/extension-sdk/admin';
|
|
3
|
+
import { messages } from './messages.js';
|
|
4
|
+
import { ConnectOrderPanel } from './order-panel.js';
|
|
5
|
+
import { ConnectSettingsPanel } from './settings-panel.js';
|
|
6
|
+
export default defineAdminExtension({
|
|
7
|
+
id: 'stripe-connect',
|
|
8
|
+
slots: {
|
|
9
|
+
'payments.settings': ConnectSettingsPanel,
|
|
10
|
+
'order.payment': ConnectOrderPanel,
|
|
11
|
+
},
|
|
12
|
+
messages,
|
|
13
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export declare const NAMESPACE = "ext.stripe-connect";
|
|
2
|
+
export declare const messages: {
|
|
3
|
+
"en-US": {
|
|
4
|
+
title: string;
|
|
5
|
+
description: string;
|
|
6
|
+
loading: string;
|
|
7
|
+
unavailable: string;
|
|
8
|
+
live: string;
|
|
9
|
+
test: string;
|
|
10
|
+
environment: string;
|
|
11
|
+
fee: string;
|
|
12
|
+
feeExplanation: string;
|
|
13
|
+
consent: string;
|
|
14
|
+
approve: string;
|
|
15
|
+
finishHub: string;
|
|
16
|
+
disable: string;
|
|
17
|
+
enable: string;
|
|
18
|
+
revoke: string;
|
|
19
|
+
connect: string;
|
|
20
|
+
refresh: string;
|
|
21
|
+
verifyStore: string;
|
|
22
|
+
hubOff: string;
|
|
23
|
+
linkChanged: string;
|
|
24
|
+
disableExplanation: string;
|
|
25
|
+
revokeExplanation: string;
|
|
26
|
+
cancel: string;
|
|
27
|
+
financials: string;
|
|
28
|
+
minorUnits: string;
|
|
29
|
+
evidenceDue: string;
|
|
30
|
+
manageDispute: string;
|
|
31
|
+
factKinds: {
|
|
32
|
+
capture: string;
|
|
33
|
+
refund: string;
|
|
34
|
+
application_fee: string;
|
|
35
|
+
fee_refund: string;
|
|
36
|
+
dispute: string;
|
|
37
|
+
};
|
|
38
|
+
orderTitle: string;
|
|
39
|
+
orderUnavailable: string;
|
|
40
|
+
};
|
|
41
|
+
"es-MX": {
|
|
42
|
+
title: string;
|
|
43
|
+
description: string;
|
|
44
|
+
loading: string;
|
|
45
|
+
unavailable: string;
|
|
46
|
+
live: string;
|
|
47
|
+
test: string;
|
|
48
|
+
environment: string;
|
|
49
|
+
fee: string;
|
|
50
|
+
feeExplanation: string;
|
|
51
|
+
consent: string;
|
|
52
|
+
approve: string;
|
|
53
|
+
finishHub: string;
|
|
54
|
+
disable: string;
|
|
55
|
+
enable: string;
|
|
56
|
+
revoke: string;
|
|
57
|
+
connect: string;
|
|
58
|
+
refresh: string;
|
|
59
|
+
verifyStore: string;
|
|
60
|
+
hubOff: string;
|
|
61
|
+
linkChanged: string;
|
|
62
|
+
disableExplanation: string;
|
|
63
|
+
revokeExplanation: string;
|
|
64
|
+
cancel: string;
|
|
65
|
+
financials: string;
|
|
66
|
+
minorUnits: string;
|
|
67
|
+
evidenceDue: string;
|
|
68
|
+
manageDispute: string;
|
|
69
|
+
factKinds: {
|
|
70
|
+
capture: string;
|
|
71
|
+
refund: string;
|
|
72
|
+
application_fee: string;
|
|
73
|
+
fee_refund: string;
|
|
74
|
+
dispute: string;
|
|
75
|
+
};
|
|
76
|
+
orderTitle: string;
|
|
77
|
+
orderUnavailable: string;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Admin messages. The admin registers them under `ext.<extension id>`.
|
|
2
|
+
export const NAMESPACE = 'ext.stripe-connect';
|
|
3
|
+
export const messages = {
|
|
4
|
+
"en-US": {
|
|
5
|
+
"title": "Stripe through Strata Connect",
|
|
6
|
+
"description": "Connect an existing eligible account or create one in Stripe. Strata holds the platform credentials.",
|
|
7
|
+
"loading": "Loading payment connections…",
|
|
8
|
+
"unavailable": "Payment status is unavailable. Retry or check your Hub connection.",
|
|
9
|
+
"live": "Live",
|
|
10
|
+
"test": "Test",
|
|
11
|
+
"environment": "Hub environment",
|
|
12
|
+
"fee": "Revenue share: {{percent}}% ({{bps}} basis points). Terms: {{terms}}.",
|
|
13
|
+
"feeExplanation": "Applies to the amount collected through Stripe, including shipping and tax, excluding gift-card tender. Customer refunds and fee returns settle separately.",
|
|
14
|
+
"consent": "As the store owner and an authorized merchant representative, I approve this account, environment, payment delegation and fee terms, including any listed historical recovery.",
|
|
15
|
+
"approve": "Approve payment connection",
|
|
16
|
+
"finishHub": "Owner approval recorded. Return to Hub to finish enrolling this store.",
|
|
17
|
+
"disable": "Disable new checkout",
|
|
18
|
+
"enable": "Enable new checkout",
|
|
19
|
+
"revoke": "Revoke this store’s payment authorization",
|
|
20
|
+
"connect": "Continue in Strata Hub",
|
|
21
|
+
"refresh": "Refresh status",
|
|
22
|
+
"verifyStore": "Connect this store to Strata Hub and confirm its API address first.",
|
|
23
|
+
"hubOff": "Strata Hub is off for this store, so card payments through Stripe Connect are unavailable. Recorded payments and their financial facts stay here. Set STRATA_HUB_KEY on the API to turn the Hub back on.",
|
|
24
|
+
"linkChanged": "This connection was made through an earlier Hub link. Connect payments again in Strata Hub.",
|
|
25
|
+
"disableExplanation": "Disabling stops new checkouts and schedules cancellation of unconfirmed attempts. Existing refunds and financial facts retain their original account association.",
|
|
26
|
+
"revokeExplanation": "This revokes this store’s service credential. Already issued client secrets may still confirm, and refunds may need a fresh owner-approved connection. To disconnect Strata from the Stripe account for every store, use Hub.",
|
|
27
|
+
"cancel": "Keep authorization",
|
|
28
|
+
"financials": "Payment reconciliation",
|
|
29
|
+
"minorUnits": "minor units",
|
|
30
|
+
"evidenceDue": "Evidence due",
|
|
31
|
+
"manageDispute": "Manage dispute in Stripe",
|
|
32
|
+
"factKinds": {
|
|
33
|
+
"capture": "Customer payment",
|
|
34
|
+
"refund": "Customer refund",
|
|
35
|
+
"application_fee": "Collected application fee",
|
|
36
|
+
"fee_refund": "Application-fee return",
|
|
37
|
+
"dispute": "Dispute"
|
|
38
|
+
},
|
|
39
|
+
"orderTitle": "Stripe Connect fees and disputes",
|
|
40
|
+
"orderUnavailable": "Stripe Connect details are unavailable for this order."
|
|
41
|
+
},
|
|
42
|
+
"es-MX": {
|
|
43
|
+
"title": "Stripe mediante Strata Connect",
|
|
44
|
+
"description": "Conecta una cuenta elegible existente o crea una en Stripe. Strata conserva las credenciales de la plataforma.",
|
|
45
|
+
"loading": "Cargando conexiones de pago…",
|
|
46
|
+
"unavailable": "El estado de pagos no está disponible. Reintenta o revisa la conexión con Hub.",
|
|
47
|
+
"live": "En vivo",
|
|
48
|
+
"test": "Prueba",
|
|
49
|
+
"environment": "Entorno de Hub",
|
|
50
|
+
"fee": "Participación en ingresos: {{percent}}% ({{bps}} puntos base). Condiciones: {{terms}}.",
|
|
51
|
+
"feeExplanation": "Se aplica al importe cobrado mediante Stripe, incluidos envío e impuestos, sin incluir tarjetas de regalo. Los reembolsos al cliente y las devoluciones de comisiones se liquidan por separado.",
|
|
52
|
+
"consent": "Como propietario de la tienda y representante autorizado del comercio, apruebo esta cuenta, entorno, delegación de pagos y condiciones de comisiones, incluida cualquier recuperación histórica indicada.",
|
|
53
|
+
"approve": "Aprobar conexión de pagos",
|
|
54
|
+
"finishHub": "Se registró la aprobación del propietario. Regresa a Hub para terminar de registrar esta tienda.",
|
|
55
|
+
"disable": "Desactivar nuevos pagos",
|
|
56
|
+
"enable": "Activar nuevos pagos",
|
|
57
|
+
"revoke": "Revocar la autorización de pagos de esta tienda",
|
|
58
|
+
"connect": "Continuar en Strata Hub",
|
|
59
|
+
"refresh": "Actualizar estado",
|
|
60
|
+
"verifyStore": "Primero conecta esta tienda a Strata Hub y confirma la dirección de su API.",
|
|
61
|
+
"hubOff": "Strata Hub está desactivado en esta tienda, así que los pagos con tarjeta mediante Stripe Connect no están disponibles. Los pagos registrados y sus datos financieros se conservan aquí. Define STRATA_HUB_KEY en la API para volver a activar Hub.",
|
|
62
|
+
"linkChanged": "Esta conexión se hizo mediante un enlace anterior de Hub. Vuelve a conectar los pagos en Strata Hub.",
|
|
63
|
+
"disableExplanation": "La desactivación impide nuevos pagos y programa la cancelación de intentos sin confirmar. Los reembolsos y registros financieros existentes conservan su asociación con la cuenta original.",
|
|
64
|
+
"revokeExplanation": "Se revoca la credencial de servicio de esta tienda. Los secretos de cliente ya emitidos aún pueden confirmar pagos, y los reembolsos pueden requerir una conexión nueva aprobada por el propietario. Para desconectar Strata de la cuenta de Stripe en todas las tiendas, usa Hub.",
|
|
65
|
+
"cancel": "Conservar autorización",
|
|
66
|
+
"financials": "Conciliación de pagos",
|
|
67
|
+
"minorUnits": "unidades menores",
|
|
68
|
+
"evidenceDue": "Fecha límite para las pruebas",
|
|
69
|
+
"manageDispute": "Gestionar disputa en Stripe",
|
|
70
|
+
"factKinds": {
|
|
71
|
+
"capture": "Pago del cliente",
|
|
72
|
+
"refund": "Reembolso al cliente",
|
|
73
|
+
"application_fee": "Comisión de aplicación cobrada",
|
|
74
|
+
"fee_refund": "Devolución de comisión de aplicación",
|
|
75
|
+
"dispute": "Disputa"
|
|
76
|
+
},
|
|
77
|
+
"orderTitle": "Comisiones y disputas de Stripe Connect",
|
|
78
|
+
"orderUnavailable": "Los detalles de Stripe Connect no están disponibles para este pedido."
|
|
79
|
+
}
|
|
80
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useLocale, useT } from '@stratacommerce/i18n';
|
|
5
|
+
import { FactList } from './fact-list.js';
|
|
6
|
+
import { NAMESPACE } from './messages.js';
|
|
7
|
+
// Fees, fee returns and disputes for one order. Captures and refunds already
|
|
8
|
+
// show in core's order financials, so they are left out here.
|
|
9
|
+
const shown = new Set(['application_fee', 'fee_refund', 'dispute']);
|
|
10
|
+
export function ConnectOrderPanel({ api, order }) {
|
|
11
|
+
const t = useT(NAMESPACE);
|
|
12
|
+
const { locale } = useLocale();
|
|
13
|
+
const [facts, setFacts] = useState(null), [failed, setFailed] = useState(false);
|
|
14
|
+
const hasConnectPayment = order.payments.some((payment) => payment.provider === 'stripe-connect:card');
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setFacts(null);
|
|
17
|
+
setFailed(false);
|
|
18
|
+
if (!hasConnectPayment)
|
|
19
|
+
return;
|
|
20
|
+
let current = true;
|
|
21
|
+
api.get(`/orders/${encodeURIComponent(order.id)}/financials`)
|
|
22
|
+
.then((rows) => { if (current)
|
|
23
|
+
setFacts(rows.filter((fact) => shown.has(fact.kind))); })
|
|
24
|
+
.catch(() => { if (current)
|
|
25
|
+
setFailed(true); });
|
|
26
|
+
return () => { current = false; };
|
|
27
|
+
}, [api, order.id, hasConnectPayment]);
|
|
28
|
+
if (!hasConnectPayment)
|
|
29
|
+
return null;
|
|
30
|
+
if (failed)
|
|
31
|
+
return _jsx("p", { role: "alert", className: "type-body text-muted-foreground", children: t('orderUnavailable') });
|
|
32
|
+
if (!facts?.length)
|
|
33
|
+
return null;
|
|
34
|
+
return _jsxs("section", { className: "sa-card", children: [_jsx("div", { className: "sa-card__head", children: _jsx("h2", { className: "sa-card__title type-card-title", children: t('orderTitle') }) }), _jsx("div", { className: "sa-card__body", children: _jsx(FactList, { facts: facts, locale: locale }) })] });
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useLocale, useT } from '@stratacommerce/i18n';
|
|
5
|
+
import { FactList } from './fact-list.js';
|
|
6
|
+
import { NAMESPACE } from './messages.js';
|
|
7
|
+
export function ConnectSettingsPanel({ api, navigate = (url) => window.location.assign(url) }) {
|
|
8
|
+
const t = useT(NAMESPACE);
|
|
9
|
+
const { locale } = useLocale();
|
|
10
|
+
const [connections, setConnections] = useState([]), [hubOn, setHubOn] = useState(true), [facts, setFacts] = useState([]), [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [error, setError] = useState(''), [accepted, setAccepted] = useState({}), [notice, setNotice] = useState(''), [revoke, setRevoke] = useState(null);
|
|
11
|
+
async function refresh() {
|
|
12
|
+
const [status, financials] = await Promise.all([api.get('/status'), api.get('/financials')]);
|
|
13
|
+
setHubOn(status.hub !== 'disabled');
|
|
14
|
+
setConnections(status.connections);
|
|
15
|
+
setFacts(financials);
|
|
16
|
+
}
|
|
17
|
+
async function run(work) { setBusy(true); setError(''); setNotice(''); try {
|
|
18
|
+
await work();
|
|
19
|
+
}
|
|
20
|
+
catch (failure) {
|
|
21
|
+
setError(failure instanceof Error ? failure.message : t('unavailable'));
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
setBusy(false);
|
|
25
|
+
} }
|
|
26
|
+
useEffect(() => { refresh().catch(() => setError(t('unavailable'))).finally(() => setLoading(false)); }, []);
|
|
27
|
+
return _jsxs("section", { className: "sa-card", children: [_jsxs("div", { className: "sa-card__head", children: [_jsx("h2", { className: "sa-card__title type-card-title", children: t('title') }), _jsx("p", { className: "sa-card__description type-caption", children: t('description') })] }), _jsxs("div", { className: "sa-card__body space-y-5", children: [loading && _jsx("p", { role: "status", children: t('loading') }), error && _jsx("p", { role: "alert", children: error }), notice && _jsx("p", { role: "status", children: notice }), !loading && !hubOn && _jsx("p", { role: "status", children: t('hubOff') }), connections.map((connection) => _jsxs("section", { className: "space-y-3 border-b pb-4 break-words", children: [_jsxs("p", { className: "font-medium", children: [connection.scope.stripeAccountId, " \u00B7 ", connection.scope.livemode ? t('live') : t('test'), " \u00B7 ", connection.state] }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [t('environment'), ": ", connection.scope.hubEnvironmentId, " \u00B7 ", connection.scope.contextId] }), hubOn && !connection.linkCurrent && connection.state !== 'revoked' && _jsx("p", { role: "alert", className: "text-sm", children: t('linkChanged') }), _jsx("p", { className: "text-sm", children: t('fee', { bps: connection.policy.revenueShareBps, percent: (connection.policy.revenueShareBps / 100).toFixed(2), terms: connection.policy.termsVersion }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('feeExplanation') }), hubOn && connection.state === 'pending' && connection.linkCurrent && _jsxs(_Fragment, { children: [_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: !!accepted[connection.id], onChange: (e) => setAccepted({ ...accepted, [connection.id]: e.target.checked }) }), t('consent')] }), _jsx("button", { type: "button", className: "sa-btn type-control sa-btn--primary sa-btn--default", disabled: busy || !accepted[connection.id], onClick: () => run(async () => { await api.post(`/approvals/${connection.id}/approve`, { termsVersion: connection.policy.termsVersion, representativeAccepted: true }); await refresh(); setNotice(t('finishHub')); }), children: t('approve') })] }), hubOn && connection.state === 'approved' && _jsx("p", { role: "status", children: t('finishHub') }), hubOn && ['active', 'disabled'].includes(connection.state) && _jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsx("button", { type: "button", className: "sa-btn type-control sa-btn--outline sa-btn--default", disabled: busy, onClick: () => run(async () => { await api.post(`/connections/${connection.id}/enabled`, { enabled: connection.state !== 'active' }); await refresh(); }), children: connection.state === 'active' ? t('disable') : t('enable') }), _jsx("button", { type: "button", className: "sa-btn type-control sa-btn--danger sa-btn--default", disabled: busy, onClick: () => setRevoke(connection.id), children: t('revoke') })] })] }, connection.id)), _jsxs("div", { className: "flex flex-wrap gap-3", children: [hubOn && _jsx("button", { type: "button", className: "sa-btn type-control sa-btn--primary sa-btn--default", disabled: busy, onClick: () => run(async () => { const result = await api.get('/connect-url'); if (!result.available || !result.url)
|
|
28
|
+
throw new Error(result.reason || t('verifyStore')); navigate(result.url); }), children: t('connect') }), _jsx("button", { type: "button", className: "sa-btn type-control sa-btn--outline sa-btn--default", disabled: busy, onClick: () => run(refresh), children: t('refresh') })] }), hubOn && _jsx("p", { className: "text-sm text-muted-foreground", children: t('disableExplanation') }), revoke && _jsxs("div", { role: "alertdialog", "aria-labelledby": "stripe-connect-revoke-title", "aria-describedby": "stripe-connect-revoke-description", className: "space-y-3 border-t pt-4", children: [_jsx("h3", { id: "stripe-connect-revoke-title", className: "font-medium", children: t('revoke') }), _jsx("p", { id: "stripe-connect-revoke-description", className: "text-sm", children: t('revokeExplanation') }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsx("button", { type: "button", className: "sa-btn type-control sa-btn--outline sa-btn--default", disabled: busy, onClick: () => setRevoke(null), children: t('cancel') }), _jsx("button", { type: "button", className: "sa-btn type-control sa-btn--danger sa-btn--default", disabled: busy, onClick: () => run(async () => { await api.post(`/connections/${revoke}/revoke`, {}); setRevoke(null); await refresh(); }), children: t('revoke') })] })] }), !!facts.length && _jsxs("section", { className: "space-y-3", children: [_jsx("h3", { className: "font-medium", children: t('financials') }), _jsx(FactList, { facts: facts, locale: locale })] })] })] });
|
|
29
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type ExtensionContext, type HubLink } from '@stratacommerce/extension-sdk';
|
|
2
|
+
import { type ConnectApproval, type ConnectEnrollmentReceipt } from '@stratacommerce/hub-protocol';
|
|
3
|
+
export declare function createEnrollments(ctx: ExtensionContext): {
|
|
4
|
+
recordChallenge(link: HubLink, input: unknown): Promise<{
|
|
5
|
+
received: boolean;
|
|
6
|
+
}>;
|
|
7
|
+
/** The owner approves locally; the admin route requires the owner role. */
|
|
8
|
+
approve(actorId: string, id: string, input: unknown): Promise<{
|
|
9
|
+
approved: boolean;
|
|
10
|
+
}>;
|
|
11
|
+
enroll(link: HubLink, id: string, input: unknown): Promise<ConnectEnrollmentReceipt>;
|
|
12
|
+
approval(link: HubLink, id: string): Promise<ConnectApproval>;
|
|
13
|
+
refreshKeys(link: HubLink, id: string, input: unknown): Promise<{
|
|
14
|
+
refreshed: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
/** Enrollments for the admin. `linkCurrent` is false once the store link they were enrolled through is gone or unusable here. */
|
|
17
|
+
status(): Promise<{
|
|
18
|
+
id: string;
|
|
19
|
+
scope: {
|
|
20
|
+
livemode: boolean;
|
|
21
|
+
hubEnvironmentId: string;
|
|
22
|
+
generation: number;
|
|
23
|
+
linkId: string;
|
|
24
|
+
hubOrigin: string;
|
|
25
|
+
hubAccountId: string;
|
|
26
|
+
hubStoreId: string;
|
|
27
|
+
paymentBindingId: string;
|
|
28
|
+
platformId: string;
|
|
29
|
+
stripeAccountId: string;
|
|
30
|
+
contextId: string;
|
|
31
|
+
environmentRole: "development" | "staging" | "production";
|
|
32
|
+
};
|
|
33
|
+
policy: {
|
|
34
|
+
id: string;
|
|
35
|
+
termsVersion: string;
|
|
36
|
+
revenueShareBps: number;
|
|
37
|
+
feeBase: "stripe_collected_gross";
|
|
38
|
+
rounding: "floor";
|
|
39
|
+
refundPolicy: "proportional_after_success";
|
|
40
|
+
disputePolicy: "return_on_final_loss";
|
|
41
|
+
};
|
|
42
|
+
state: "pending" | "disabled" | "approved" | "active" | "revoked";
|
|
43
|
+
expiresAt: Date;
|
|
44
|
+
recoveryBindingIds: string[];
|
|
45
|
+
linkCurrent: boolean;
|
|
46
|
+
}[]>;
|
|
47
|
+
credentials(id: string): Promise<{
|
|
48
|
+
clientId: string;
|
|
49
|
+
token: string;
|
|
50
|
+
id: string;
|
|
51
|
+
expiresAt: Date;
|
|
52
|
+
state: "pending" | "disabled" | "approved" | "active" | "revoked";
|
|
53
|
+
linkId: string;
|
|
54
|
+
scope: {
|
|
55
|
+
livemode: boolean;
|
|
56
|
+
hubEnvironmentId: string;
|
|
57
|
+
generation: number;
|
|
58
|
+
linkId: string;
|
|
59
|
+
hubOrigin: string;
|
|
60
|
+
hubAccountId: string;
|
|
61
|
+
hubStoreId: string;
|
|
62
|
+
paymentBindingId: string;
|
|
63
|
+
platformId: string;
|
|
64
|
+
stripeAccountId: string;
|
|
65
|
+
contextId: string;
|
|
66
|
+
environmentRole: "development" | "staging" | "production";
|
|
67
|
+
};
|
|
68
|
+
policy: {
|
|
69
|
+
id: string;
|
|
70
|
+
termsVersion: string;
|
|
71
|
+
revenueShareBps: number;
|
|
72
|
+
feeBase: "stripe_collected_gross";
|
|
73
|
+
rounding: "floor";
|
|
74
|
+
refundPolicy: "proportional_after_success";
|
|
75
|
+
disputePolicy: "return_on_final_loss";
|
|
76
|
+
};
|
|
77
|
+
publicKeys: Record<string, string>;
|
|
78
|
+
recoveryBindingIds: string[];
|
|
79
|
+
approvedBy: string | null;
|
|
80
|
+
nonceHash: string;
|
|
81
|
+
tokenBox: string | null;
|
|
82
|
+
approvedAt: Date | null;
|
|
83
|
+
createdAt: Date;
|
|
84
|
+
}>;
|
|
85
|
+
setEnabled(id: string, enabled: boolean): Promise<{
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
}>;
|
|
88
|
+
revoke(id: string): Promise<{
|
|
89
|
+
connected: boolean;
|
|
90
|
+
}>;
|
|
91
|
+
};
|
|
92
|
+
export type Enrollments = ReturnType<typeof createEnrollments>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash, createPublicKey } from 'node:crypto';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { AppError } from '@stratacommerce/extension-sdk';
|
|
6
|
+
import { connectChallengeSchema, connectEnrollmentSchema, connectKeyRefreshSchema } from '@stratacommerce/hub-protocol';
|
|
7
|
+
import { assertConnectLink, hubOff } from './identity.js';
|
|
8
|
+
import { enrollments } from './schema.js';
|
|
9
|
+
// Connect enrollment on the store link (docs/hub-store-link.md). The Hub
|
|
10
|
+
// calls these with its Hub token; the extension accepts them, and runs
|
|
11
|
+
// payment work, only while the enrollment's link is the active link and
|
|
12
|
+
// usable here.
|
|
13
|
+
const hash = (s) => createHash('sha256').update(s).digest('hex');
|
|
14
|
+
const approvalSchema = z.object({ termsVersion: z.string(), representativeAccepted: z.literal(true) }).strict();
|
|
15
|
+
const credentialSchema = connectEnrollmentSchema.omit({ nonce: true });
|
|
16
|
+
// The payment client token is sealed by core, bound to this enrollment, the
|
|
17
|
+
// store link and this environment's address.
|
|
18
|
+
const purpose = (id) => `enrollment:${id}`;
|
|
19
|
+
export function createEnrollments(ctx) {
|
|
20
|
+
const db = ctx.db;
|
|
21
|
+
async function seal(value, id) {
|
|
22
|
+
return ctx.hub.seal(purpose(id), JSON.stringify(value));
|
|
23
|
+
}
|
|
24
|
+
async function open(box, id) {
|
|
25
|
+
const opened = await ctx.hub.open(purpose(id), box);
|
|
26
|
+
const parsed = opened === null ? null : credentialSchema.safeParse(JSON.parse(opened));
|
|
27
|
+
if (!parsed?.success)
|
|
28
|
+
throw new AppError('The payment credential does not open in this environment. Reconnect payments.', 'PAYMENT_AUTHORIZATION_REVOKED', 409);
|
|
29
|
+
return parsed.data;
|
|
30
|
+
}
|
|
31
|
+
async function row(id) {
|
|
32
|
+
const [value] = await db.select().from(enrollments).where(eq(enrollments.id, z.string().uuid().parse(id)));
|
|
33
|
+
if (!value)
|
|
34
|
+
throw new AppError('Payment connection not found', 'PAYMENT_CONNECTION_NOT_FOUND', 404);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
/** The enrollment's link must be the link in use: the Hub's caller, or this environment's usable active link. */
|
|
38
|
+
function belongs(value, link) {
|
|
39
|
+
assertConnectLink(value.scope, link, ctx.environment);
|
|
40
|
+
if (value.linkId !== link.linkId)
|
|
41
|
+
throw new AppError('This payment connection belongs to another Hub link. Reconnect payments in Strata Hub.', 'PAYMENT_ENVIRONMENT_MISMATCH', 409);
|
|
42
|
+
}
|
|
43
|
+
async function usable(value) {
|
|
44
|
+
const { state, link } = await ctx.hub.status();
|
|
45
|
+
if (state === 'disabled')
|
|
46
|
+
throw hubOff();
|
|
47
|
+
belongs(value, link);
|
|
48
|
+
return link;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
async recordChallenge(link, input) {
|
|
52
|
+
const parsed = connectChallengeSchema.safeParse(input);
|
|
53
|
+
if (!parsed.success)
|
|
54
|
+
throw new AppError('Use a current payment challenge from Hub', 'PAYMENT_CHALLENGE_INVALID', 400);
|
|
55
|
+
const value = parsed.data, expiry = Date.parse(value.expiresAt);
|
|
56
|
+
// A challenge lasts at most five minutes on the Hub's clock, which may run a minute ahead of this one.
|
|
57
|
+
if (expiry <= Date.now() || expiry > Date.now() + 300000 + 60000 || !Object.keys(value.publicKeys).length)
|
|
58
|
+
throw new AppError('Use a current payment challenge from Hub', 'PAYMENT_CHALLENGE_INVALID', 400);
|
|
59
|
+
assertConnectLink(value.scope, link, ctx.environment);
|
|
60
|
+
for (const pem of Object.values(value.publicKeys)) {
|
|
61
|
+
try {
|
|
62
|
+
if (createPublicKey(pem).asymmetricKeyType !== 'ed25519')
|
|
63
|
+
throw Error();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new AppError('Payment delivery keys must use Ed25519', 'PAYMENT_CHALLENGE_INVALID', 400);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
await db.transaction(async (tx) => {
|
|
70
|
+
await tx.insert(enrollments).values({ id: value.scope.paymentBindingId, linkId: link.linkId, scope: value.scope, policy: value.policy, state: 'pending', nonceHash: hash(value.nonce),
|
|
71
|
+
expiresAt: new Date(expiry), publicKeys: value.publicKeys, recoveryBindingIds: value.recoveryBindingIds }).onConflictDoNothing();
|
|
72
|
+
const [existing] = await tx.select().from(enrollments).where(eq(enrollments.id, value.scope.paymentBindingId)).for('update');
|
|
73
|
+
if (existing.linkId !== link.linkId || existing.nonceHash !== hash(value.nonce) || !isDeepStrictEqual(existing.scope, value.scope) || !isDeepStrictEqual(existing.policy, value.policy) ||
|
|
74
|
+
!isDeepStrictEqual(existing.publicKeys, value.publicKeys) || !isDeepStrictEqual(existing.recoveryBindingIds, value.recoveryBindingIds))
|
|
75
|
+
throw new AppError('Restart payment authorization', 'PAYMENT_CHALLENGE_INVALID', 409);
|
|
76
|
+
});
|
|
77
|
+
return { received: true };
|
|
78
|
+
},
|
|
79
|
+
/** The owner approves locally; the admin route requires the owner role. */
|
|
80
|
+
async approve(actorId, id, input) {
|
|
81
|
+
const accepted = approvalSchema.parse(input);
|
|
82
|
+
id = z.string().uuid().parse(id);
|
|
83
|
+
return db.transaction(async (tx) => {
|
|
84
|
+
const [value] = await tx.select().from(enrollments).where(eq(enrollments.id, id)).for('update');
|
|
85
|
+
if (!value || value.state !== 'pending' || value.expiresAt <= new Date() || accepted.termsVersion !== value.policy.termsVersion)
|
|
86
|
+
throw new AppError('Review a current payment approval', 'PAYMENT_CHALLENGE_INVALID', 409);
|
|
87
|
+
await usable(value);
|
|
88
|
+
await tx.update(enrollments).set({ state: 'approved', approvedBy: actorId, approvedAt: new Date() }).where(eq(enrollments.id, id));
|
|
89
|
+
return { approved: true };
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
async enroll(link, id, input) {
|
|
93
|
+
const accepted = connectEnrollmentSchema.parse(input), value = await row(id);
|
|
94
|
+
belongs(value, link);
|
|
95
|
+
return db.transaction(async (tx) => {
|
|
96
|
+
const [current] = await tx.select().from(enrollments).where(eq(enrollments.id, id)).for('update');
|
|
97
|
+
if (current.state === 'active' && current.tokenBox) {
|
|
98
|
+
const saved = await open(current.tokenBox, id);
|
|
99
|
+
if (saved.clientId === accepted.clientId && hash(saved.token) === hash(accepted.token))
|
|
100
|
+
return { clientId: saved.clientId };
|
|
101
|
+
throw new AppError('Create a fresh payment authorization', 'PAYMENT_ENROLLMENT_USED', 409);
|
|
102
|
+
}
|
|
103
|
+
if (current.state !== 'approved')
|
|
104
|
+
throw new AppError('The store owner must approve this payment connection', 'PAYMENT_OWNER_APPROVAL_REQUIRED', 409);
|
|
105
|
+
if (current.expiresAt <= new Date() || current.nonceHash !== hash(accepted.nonce))
|
|
106
|
+
throw new AppError('Create a fresh payment authorization', 'PAYMENT_CHALLENGE_INVALID', 409);
|
|
107
|
+
// Configure checkout before exposing an active client to Hub recovery.
|
|
108
|
+
// If either write fails, the enrollment stays approved and Hub resends
|
|
109
|
+
// it. A method pointing at an approved enrollment cannot take payments.
|
|
110
|
+
// The row lock also keeps completed replays from changing owner choices.
|
|
111
|
+
await ctx.payments.setMethod('card', { enabled: true, config: { paymentBindingId: id } });
|
|
112
|
+
// One enrollment per Hub environment and payment context, as on the Hub.
|
|
113
|
+
const previous = await tx.select().from(enrollments);
|
|
114
|
+
for (const prior of previous)
|
|
115
|
+
if (prior.id !== id && ['active', 'disabled'].includes(prior.state) && prior.scope.hubEnvironmentId === value.scope.hubEnvironmentId && prior.scope.contextId === value.scope.contextId)
|
|
116
|
+
await tx.update(enrollments).set({ state: 'revoked', tokenBox: null }).where(eq(enrollments.id, prior.id));
|
|
117
|
+
await tx.update(enrollments).set({ state: 'active', tokenBox: await seal({ clientId: accepted.clientId, token: accepted.token }, id) }).where(eq(enrollments.id, id));
|
|
118
|
+
return { clientId: accepted.clientId };
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
async approval(link, id) {
|
|
122
|
+
const value = await row(id);
|
|
123
|
+
belongs(value, link);
|
|
124
|
+
return { state: value.state, scope: value.scope, policy: value.policy, clientId: value.tokenBox ? (await open(value.tokenBox, id)).clientId : null, approvedBy: value.approvedBy };
|
|
125
|
+
},
|
|
126
|
+
async refreshKeys(link, id, input) {
|
|
127
|
+
const value = await row(id);
|
|
128
|
+
belongs(value, link);
|
|
129
|
+
if (!['active', 'disabled'].includes(value.state))
|
|
130
|
+
throw new AppError('Payment key refresh is not authorized', 'PAYMENT_AUTHORIZATION_REVOKED', 409);
|
|
131
|
+
const { publicKeys } = connectKeyRefreshSchema.parse(input);
|
|
132
|
+
if (!Object.keys(publicKeys).length || Object.keys(publicKeys).length > 10)
|
|
133
|
+
throw new AppError('Provide the current delivery keys', 'PAYMENT_CHALLENGE_INVALID', 400);
|
|
134
|
+
for (const pem of Object.values(publicKeys))
|
|
135
|
+
if (createPublicKey(pem).asymmetricKeyType !== 'ed25519')
|
|
136
|
+
throw new AppError('Use Ed25519 delivery keys', 'PAYMENT_CHALLENGE_INVALID', 400);
|
|
137
|
+
await db.update(enrollments).set({ publicKeys }).where(eq(enrollments.id, id));
|
|
138
|
+
return { refreshed: true };
|
|
139
|
+
},
|
|
140
|
+
/** Enrollments for the admin. `linkCurrent` is false once the store link they were enrolled through is gone or unusable here. */
|
|
141
|
+
async status() {
|
|
142
|
+
const rows = await db.select().from(enrollments);
|
|
143
|
+
if (!rows.length)
|
|
144
|
+
return [];
|
|
145
|
+
const link = await ctx.hub.link();
|
|
146
|
+
return rows.map(({ id, scope, policy, state, expiresAt, recoveryBindingIds, linkId }) => ({ id, scope, policy, state, expiresAt, recoveryBindingIds, linkCurrent: link?.linkId === linkId }));
|
|
147
|
+
},
|
|
148
|
+
async credentials(id) {
|
|
149
|
+
const value = await row(id);
|
|
150
|
+
await usable(value);
|
|
151
|
+
if (!['active', 'disabled'].includes(value.state) || !value.tokenBox)
|
|
152
|
+
throw new AppError('Reconnect this payment account', 'PAYMENT_AUTHORIZATION_REVOKED', 409);
|
|
153
|
+
return { ...value, ...await open(value.tokenBox, id) };
|
|
154
|
+
},
|
|
155
|
+
async setEnabled(id, enabled) {
|
|
156
|
+
const value = await row(id);
|
|
157
|
+
// Disabling is always allowed; enabling needs the enrollment's link to be usable here.
|
|
158
|
+
if (enabled)
|
|
159
|
+
await usable(value);
|
|
160
|
+
if (!['active', 'disabled'].includes(value.state))
|
|
161
|
+
throw new AppError('Reconnect this payment account', 'PAYMENT_AUTHORIZATION_REVOKED', 409);
|
|
162
|
+
await db.update(enrollments).set({ state: enabled ? 'active' : 'disabled' }).where(eq(enrollments.id, id));
|
|
163
|
+
if (!enabled)
|
|
164
|
+
await ctx.payments.cancelPendingCheckouts(id);
|
|
165
|
+
return { enabled };
|
|
166
|
+
},
|
|
167
|
+
async revoke(id) {
|
|
168
|
+
id = z.string().uuid().parse(id);
|
|
169
|
+
await db.update(enrollments).set({ state: 'revoked', tokenBox: null }).where(eq(enrollments.id, id));
|
|
170
|
+
return { connected: false };
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|