@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.219
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/package.json +7 -7
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +156 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +144 -0
- package/src/lib/server/api-v1-resources.js +126 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +41 -16
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.d.ts +23 -0
- package/src/lib/server/crm-inbound-email.js +1 -16
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +4 -1
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +36 -1
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +1 -1
- package/src/lib/server/member-email-aliases.js +1 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +8 -2
- package/src/lib/server/notifications.js +11 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +85 -18
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +4 -5
- package/src/tenancy.js +11 -6
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { operatorAlertForNotificationType } from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off, so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Rendered through the `notification` system email (AGL-3367), which is what\n * gives it the header and footer every other system email wears, and makes\n * it designable on the staff System emails page. It used to hand `sendEmail`\n * bare text, which is the unbranded card the fallback HTML part draws. The\n * template is read once per fan-out and rendered per recipient.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n audience: NotifyUsersOptions['audience'],\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const context = await notificationEmailContext(payload, audience)\n const url = notificationEmailUrl(payload, context, origin)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n url,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...context.brand.merge,\n 'notification.title': payload.title,\n 'notification.body': payload.body ?? '',\n 'notification.url': url,\n settingsUrl: origin ? settingsUrl : '',\n },\n context.brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject: payload.title,\n text: fallbackText,\n }),\n ...(context.brand.fromName ? { fromName: context.brand.fromName } : {}),\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification to a workspace's people is that\n // workspace's mail, and a staff alert or an account-level notice is the\n // platform's, whichever org it is about.\n await (payload.orgId && audience !== 'staff'\n ? meterOrgEmail(payload.orgId)\n : meterPlatformEmail()\n ).catch(() => undefined)\n }\n}\n\n/** What a notification email needs from the org and site it is about. */\ninterface NotificationEmailContext {\n brand: SystemEmailBrand\n orgSlug?: string\n hostSubdomain?: string\n}\n\n/**\n * The org's brand, and the slug and subdomain its stored link is rewritten\n * with (AGL-3367): at most two reads per fan-out, made only when someone is\n * being emailed.\n *\n * A staff fan-out reads nothing. Its brand is the platform's, and its links\n * are staff routes that need no rewriting.\n */\nasync function notificationEmailContext(\n payload: NotificationPayload,\n audience: NotifyUsersOptions['audience'],\n): Promise<NotificationEmailContext> {\n if (audience === 'staff' || !payload.orgId) {\n return { brand: systemEmailBrand(null) }\n }\n try {\n const db = firestore()\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId\n ? db.collection('hosts').doc(payload.hostId).get()\n : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return {\n brand: systemEmailBrand(org.exists ? (org.data() ?? null) : null),\n ...(typeof slug === 'string' && slug ? { orgSlug: slug } : {}),\n ...(typeof subdomain === 'string' && subdomain\n ? { hostSubdomain: subdomain }\n : {}),\n }\n } catch (error) {\n console.error('notification email context failed', error)\n return { brand: systemEmailBrand(null) }\n }\n}\n\n/**\n * Where the email's button goes: the notification's own link, rewritten onto\n * today's routes the way the console rewrites it when it is clicked, else\n * the notifications page. A stored host link is `/{hostDocId}/…`, which only\n * that rewrite turns into a page that exists. Empty when the deployment has\n * no console origin to make a link absolute with.\n */\nfunction notificationEmailUrl(\n payload: NotificationPayload,\n context: NotificationEmailContext,\n origin: string,\n): string {\n const link = normalizeNotificationLink(payload.link, {\n orgSlug: context.orgSlug,\n hostId: payload.hostId,\n hostSubdomain: context.hostSubdomain,\n })\n if (link && /^https?:\\/\\//i.test(link)) return link\n if (!origin) return ''\n return `${origin}${link?.startsWith('/') ? link : buildRoute(Route.MANAGE_NOTIFICATIONS)}`\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n *\n * A locked or disabled account is not mailed (AGL-3418); its feed entry\n * above still records the event.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n const reachable = await withoutMailWithheldAccounts(mailTo)\n if (!reachable.length) return\n await emailNotification(\n reachable,\n payload,\n options.emails ?? {},\n options.audience,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n *\n * A type the operator alert registry names (AGL-3377) — a fraud or risk\n * alert, a dispute nobody owns, a support ticket — is raised as that alert\n * instead: the same console notification, plus staff's switches, the\n * operator email or digest, and the webhook. See `raiseOperatorAlert`.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n const alert = operatorAlertForNotificationType(payload.type)\n if (alert) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(alert, {\n subject: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n ...(payload.link ? { url: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n })\n return\n }\n await notifyStaffConsole(payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * The staff console notification alone, with the per-person email channel\n * it carries: what `notifyStaff` did before the registry, and what\n * `raiseOperatorAlert` writes as its first step. Never throws.\n */\nexport async function notifyStaffConsole(\n payload: NotificationPayload,\n options: { skipEmail?: boolean } = {},\n): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(options.skipEmail ? { skipEmail: true } : {}),\n })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\n })\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","normalizeNotificationLink","notificationChannelEnabled","Route","operatorAlertForNotificationType","isEmailConfigured","sendEmail","FieldValue","withoutMailWithheldAccounts","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","filter","Boolean","join","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","length","rendered","brand","merge","options","result","to","subject","text","fromName","headers","orgId","undefined","org","db","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","exists","data","orgSlug","hostSubdomain","error","console","link","test","startsWith","MANAGE_NOTIFICATIONS","notifyUsers","targets","Set","slice","userDocs","getAll","map","scope","batch","count","mailTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","push","commit","skipEmail","has","reachable","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","alert","raiseOperatorAlert","notifyStaffConsole","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","memberRoles","managers","Object","entries"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { operatorAlertForNotificationType } from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off, so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Rendered through the `notification` system email (AGL-3367), which is what\n * gives it the header and footer every other system email wears, and makes\n * it designable on the staff System emails page. It used to hand `sendEmail`\n * bare text, which is the unbranded card the fallback HTML part draws. The\n * template is read once per fan-out and rendered per recipient.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n audience: NotifyUsersOptions['audience'],\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const context = await notificationEmailContext(payload, audience)\n const url = notificationEmailUrl(payload, context, origin)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n url,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...context.brand.merge,\n 'notification.title': payload.title,\n 'notification.body': payload.body ?? '',\n 'notification.url': url,\n settingsUrl: origin ? settingsUrl : '',\n },\n context.brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject: payload.title,\n text: fallbackText,\n }),\n ...(context.brand.fromName ? { fromName: context.brand.fromName } : {}),\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification to a workspace's people is that\n // workspace's mail, and a staff alert or an account-level notice is the\n // platform's, whichever org it is about.\n await (payload.orgId && audience !== 'staff'\n ? meterOrgEmail(payload.orgId)\n : meterPlatformEmail()\n ).catch(() => undefined)\n }\n}\n\n/** What a notification email needs from the org and site it is about. */\ninterface NotificationEmailContext {\n brand: SystemEmailBrand\n orgSlug?: string\n hostSubdomain?: string\n}\n\n/**\n * The org's brand, and the slug and subdomain its stored link is rewritten\n * with (AGL-3367): at most two reads per fan-out, made only when someone is\n * being emailed.\n *\n * A staff fan-out reads nothing. Its brand is the platform's, and its links\n * are staff routes that need no rewriting.\n */\nasync function notificationEmailContext(\n payload: NotificationPayload,\n audience: NotifyUsersOptions['audience'],\n): Promise<NotificationEmailContext> {\n if (audience === 'staff' || !payload.orgId) {\n return { brand: systemEmailBrand(null) }\n }\n try {\n const db = firestore()\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId\n ? db.collection('hosts').doc(payload.hostId).get()\n : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return {\n brand: systemEmailBrand(org.exists ? (org.data() ?? null) : null),\n ...(typeof slug === 'string' && slug ? { orgSlug: slug } : {}),\n ...(typeof subdomain === 'string' && subdomain\n ? { hostSubdomain: subdomain }\n : {}),\n }\n } catch (error) {\n console.error('notification email context failed', error)\n return { brand: systemEmailBrand(null) }\n }\n}\n\n/**\n * Where the email's button goes: the notification's own link, rewritten onto\n * today's routes the way the console rewrites it when it is clicked, else\n * the notifications page. A stored host link is `/{hostDocId}/…`, which only\n * that rewrite turns into a page that exists. Empty when the deployment has\n * no console origin to make a link absolute with.\n */\nfunction notificationEmailUrl(\n payload: NotificationPayload,\n context: NotificationEmailContext,\n origin: string,\n): string {\n const link = normalizeNotificationLink(payload.link, {\n orgSlug: context.orgSlug,\n hostId: payload.hostId,\n hostSubdomain: context.hostSubdomain,\n })\n if (link && /^https?:\\/\\//i.test(link)) return link\n if (!origin) return ''\n return `${origin}${link?.startsWith('/') ? link : buildRoute(Route.MANAGE_NOTIFICATIONS)}`\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n *\n * A locked or disabled account is not mailed (AGL-3418); its feed entry\n * above still records the event.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n const reachable = await withoutMailWithheldAccounts(mailTo)\n if (!reachable.length) return\n await emailNotification(\n reachable,\n payload,\n options.emails ?? {},\n options.audience,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n *\n * A type the operator alert registry names (AGL-3377) — a fraud or risk\n * alert, a dispute nobody owns, a support ticket — is raised as that alert\n * instead: the same console notification, plus staff's switches, the\n * operator email or digest, and the webhook. See `raiseOperatorAlert`.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n const alert = operatorAlertForNotificationType(payload.type)\n if (alert) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(alert, {\n subject: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n ...(payload.link ? { url: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n })\n return\n }\n await notifyStaffConsole(payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * The staff console notification alone, with the per-person email channel\n * it carries: what `notifyStaff` did before the registry, and what\n * `raiseOperatorAlert` writes as its first step. Never throws.\n */\nexport async function notifyStaffConsole(\n payload: NotificationPayload,\n options: { skipEmail?: boolean } = {},\n): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(options.skipEmail ? { skipEmail: true } : {}),\n })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * Notifies the org's owner + admins (billing, membership, org events).\n *\n * `skipEmail` is for a caller that emails the same notice to the same people\n * itself — the usage alerts do (AGL-3431) — so an admin who switched the\n * email channel on is not sent it twice.\n */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n options: Pick<NotifyUsersOptions, 'skipEmail'> = {},\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails, ...(options.skipEmail ? { skipEmail: true } : {}) },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\n })\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","normalizeNotificationLink","notificationChannelEnabled","Route","operatorAlertForNotificationType","isEmailConfigured","sendEmail","FieldValue","withoutMailWithheldAccounts","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","filter","Boolean","join","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","length","rendered","brand","merge","options","result","to","subject","text","fromName","headers","orgId","undefined","org","db","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","exists","data","orgSlug","hostSubdomain","error","console","link","test","startsWith","MANAGE_NOTIFICATIONS","notifyUsers","targets","Set","slice","userDocs","getAll","map","scope","batch","count","mailTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","push","commit","skipEmail","has","reachable","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","alert","raiseOperatorAlert","notifyStaffConsole","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","memberRoles","managers","Object","entries"],"mappings":"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"}
|
package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts}
RENAMED
|
@@ -55,6 +55,9 @@
|
|
|
55
55
|
* the document the merchant and publisher surfaces already read. A card that
|
|
56
56
|
* had to open a second collection would be a second, disagreeing read of the
|
|
57
57
|
* same fact.
|
|
58
|
+
*
|
|
59
|
+
* Part of the Stripe Connect payment provider: a plugin reaches it through
|
|
60
|
+
* the contract's `applyAccountEvent`, never by importing it.
|
|
58
61
|
*/
|
|
59
62
|
/** Which leg of the money movement failed. */
|
|
60
63
|
export type ConnectPayoutFailureKind = 'payout' | 'transfer';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-provider-stripe-connect-payouts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { formatOperatorAlertAmount } from '@aglyn/aglyn/app-utils/operator-alerts'\nimport { firebaseAdmin } from './firebase-admin'\nimport { updateExisting } from './update-existing'\n\n/**\n * A PAYOUT THAT NEVER LANDED, RECORDED.\n *\n * ## What was invisible, and to whom\n *\n * Nothing in this codebase handled `payout.failed` or `transfer.failed`. The\n * consequences ran in opposite directions on the two sides of the same money:\n *\n * - **The merchant** saw a storefront that looked healthy — orders settling,\n * the payments card reporting enabled — while the funds accumulated in a\n * Connect account that could not release them. Aglyn stored no balance and\n * no payout schedule, so nothing in the console could contradict that.\n * - **The publisher** saw a seller ledger whose payout figure is derived from\n * `transferCents`, which is what Stripe was ASKED to move. A failed payout\n * leaves that number untouched, so the ledger actively asserted money had\n * arrived when it had not.\n *\n * And Aglyn held no record at all, so there was nothing to reconcile against,\n * no history, and no way to answer \"has this happened to this account before\".\n *\n * ## Recorded, surfaced — never retried\n *\n * This module only writes things down. It does NOT re-issue a payout or a\n * transfer, deliberately: Stripe runs its own retry schedule, and a second\n * transfer against an account that has just refused one is how a duplicate\n * lands. A human decides what happens next, which is the same posture the\n * refund-reversal recovery queue takes for the money Stripe refuses to pull\n * back — the sibling family of problem.\n *\n * ## Two records, because they answer different questions\n *\n * `connectPayoutFailures/{stripeId}` is the durable HISTORY, keyed by the\n * Stripe id so a redelivery converges rather than duplicating. It is written\n * even when no profile in the named collection binds the account, because\n * \"money did not arrive and we cannot say whose\" is precisely the state that\n * must not be silent.\n *\n * The profile documents get a MIRROR of the latest failure, because that is\n * the document the merchant and publisher surfaces already read. A card that\n * had to open a second collection would be a second, disagreeing read of the\n * same fact.\n *\n * Part of the Stripe Connect payment provider: a plugin reaches it through\n * the contract's `applyAccountEvent`, never by importing it.\n */\n\n/** Which leg of the money movement failed. */\nexport type ConnectPayoutFailureKind = 'payout' | 'transfer'\n\nexport interface ConnectPayoutFailureInput {\n /**\n * `payout` is the connected account's balance reaching its bank, and its\n * account id comes from `event.account` — the Payout object's own\n * `destination` is the BANK, not the Connect account. `transfer` is the\n * platform's balance reaching the connected account, a platform event whose\n * `destination` IS the account.\n */\n kind: ConnectPayoutFailureKind\n /** `event.data.object` — the Payout or Transfer. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n object: any\n /** The connected account the money was headed for. */\n accountId: string\n /** `event.livemode`. */\n livemode?: unknown\n}\n\nfunction cents(value: unknown): number {\n const amount = Math.round(Number(value ?? 0))\n return Number.isFinite(amount) ? amount : 0\n}\n\nfunction text(value: unknown): string | null {\n return typeof value === 'string' && value.trim() ? value.trim() : null\n}\n\n/**\n * The sentence a merchant reads. Stripe's `failure_message` is written for a\n * developer and is often absent, so the code is kept beside it and a missing\n * pair still produces something a human can act on rather than an empty\n * warning that looks like a rendering fault.\n */\nexport function payoutFailureReason(object: unknown): string {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const source = object as any\n return (\n text(source?.failure_message) ??\n text(source?.failure_code) ??\n 'Stripe did not say why'\n )\n}\n\n/**\n * Records one failed payout or transfer, and mirrors it onto the profiles in\n * `collection` that bind the account.\n *\n * @returns how many profile documents were mirrored onto. `0` is an ordinary\n * answer — the other plugin's collection binds this account, or nothing\n * does — and never means the failure went unrecorded.\n */\nexport async function recordConnectPayoutFailure(\n collection: string,\n input: ConnectPayoutFailureInput,\n): Promise<number> {\n const accountId = String(input?.accountId ?? '').trim()\n const stripeId = text(input?.object?.id)\n if (!accountId || !stripeId) return 0\n const firestore = firebaseAdmin.app().firestore()\n const amountCents = cents(input.object?.amount)\n const reason = payoutFailureReason(input.object)\n const failedAtMs = Date.now()\n\n const matches = await firestore\n .collection(collection)\n .where('stripeAccountId', '==', accountId)\n .get()\n\n // The durable record FIRST, and unconditionally. An account no profile binds\n // is the case with the least visibility and the most need of a written\n // trace; making the history conditional on a successful join would lose\n // exactly the failures nobody could otherwise explain.\n await firestore\n .collection('connectPayoutFailures')\n .doc(stripeId)\n .set(\n {\n kind: input.kind,\n accountId,\n amountCents,\n currency: text(input.object?.currency) ?? 'usd',\n failureCode: text(input.object?.failure_code),\n failureMessage: text(input.object?.failure_message),\n reason,\n failedAtMs,\n ...(typeof input.livemode === 'boolean'\n ? { livemode: input.livemode }\n : {}),\n // A lead a human can search, accumulated across both plugins: the same\n // account can be bound by a storefront profile and a publisher profile,\n // and each plugin records only its own.\n ...(matches.docs.length\n ? {\n boundProfiles: firebaseAdmin.firestore.FieldValue.arrayUnion(\n ...matches.docs.map((doc) => `${collection}/${doc.id}`),\n ),\n }\n : {}),\n recordedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n // Money due to a merchant or seller did not arrive, and a human decides\n // what happens next (AGL-3377). Loaded lazily, like everything this module\n // only needs once a failure is real.\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert('billing.connectPayoutFailed', {\n dedupeKey: stripeId,\n context: {\n kind: input.kind,\n stripeId,\n account: accountId,\n reason,\n amount: formatOperatorAlertAmount(amountCents, text(input.object?.currency) ?? 'usd'),\n },\n })\n\n // `updateExisting` rather than a merge-set, for the reason its sibling\n // states: the refs come from a query, and a merge-set would resurrect a\n // profile erased in between as a stub holding nothing but a payout warning.\n let mirrored = 0\n for (const doc of matches.docs) {\n if (\n await updateExisting(doc.ref, {\n lastPayoutFailureId: stripeId,\n lastPayoutFailureKind: input.kind,\n lastPayoutFailureAtMs: failedAtMs,\n lastPayoutFailureCents: amountCents,\n lastPayoutFailureReason: reason,\n })\n ) {\n mirrored += 1\n }\n }\n return mirrored\n}\n\n/**\n * Clears the mirrored warning when a later payout to the same account\n * succeeds.\n *\n * NOT a retry and not a correction of the history: `connectPayoutFailures`\n * keeps every failure, because \"has this account failed before\" is a question\n * the record exists to answer. This only stops the merchant's card asserting a\n * problem that has since resolved — a stale warning trains people to ignore\n * the surface, which costs more than it saves.\n */\nexport async function clearConnectPayoutFailure(\n collection: string,\n accountId: string,\n): Promise<number> {\n const id = String(accountId ?? '').trim()\n if (!id) return 0\n const firestore = firebaseAdmin.app().firestore()\n const matches = await firestore\n .collection(collection)\n .where('stripeAccountId', '==', id)\n .get()\n let cleared = 0\n for (const doc of matches.docs) {\n // Only touch a profile that actually carries a warning, so an ordinary\n // successful payout does not write to every profile on every event.\n if (doc.get('lastPayoutFailureAtMs') == null) continue\n const remove = firebaseAdmin.firestore.FieldValue.delete()\n if (\n await updateExisting(doc.ref, {\n lastPayoutFailureId: remove,\n lastPayoutFailureKind: remove,\n lastPayoutFailureAtMs: remove,\n lastPayoutFailureCents: remove,\n lastPayoutFailureReason: remove,\n })\n ) {\n cleared += 1\n }\n }\n return cleared\n}\n"],"names":["formatOperatorAlertAmount","firebaseAdmin","updateExisting","cents","value","amount","Math","round","Number","isFinite","text","trim","payoutFailureReason","object","source","failure_message","failure_code","recordConnectPayoutFailure","collection","input","accountId","String","stripeId","id","firestore","app","amountCents","reason","failedAtMs","Date","now","matches","where","get","doc","set","kind","currency","failureCode","failureMessage","livemode","docs","length","boundProfiles","FieldValue","arrayUnion","map","recordedAt","serverTimestamp","merge","raiseOperatorAlert","dedupeKey","context","account","mirrored","ref","lastPayoutFailureId","lastPayoutFailureKind","lastPayoutFailureAtMs","lastPayoutFailureCents","lastPayoutFailureReason","clearConnectPayoutFailure","cleared","remove","delete"],"mappings":"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"}
|
package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts}
RENAMED
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
* IDEMPOTENT AND ORDER-INDEPENDENT by construction — it mirrors current state,
|
|
48
48
|
* never a delta — so a Stripe redelivery and an out-of-order pair both
|
|
49
49
|
* converge.
|
|
50
|
+
*
|
|
51
|
+
* Part of the Stripe Connect payment provider: a plugin reaches it through
|
|
52
|
+
* the contract's `applyAccountEvent`, never by importing it.
|
|
50
53
|
*/
|
|
51
54
|
export interface ConnectAccountStatusEvent {
|
|
52
55
|
/** `event.data.object` for `account.updated` — the Stripe Account. */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-provider-stripe-connect-status.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from './firebase-admin'\nimport { sendGa4StripeConnected } from './ga4-measurement-protocol'\nimport { updateExisting } from './update-existing'\n\n/**\n * Connect readiness, refreshed from Stripe rather than from a cached flag\n * (AGL-1997).\n *\n * `stripeChargesEnabled` is the field every money route gates on — commerce\n * checkout, cart checkout, draft orders, reservations, POS — and until this it\n * was refreshed ONLY when the merchant happened to reopen the Connect route.\n * So an account Stripe later restricts (a verification deadline passing, a\n * capability revoked, documents going stale) kept a cached `true`, the store\n * kept selling, and the failure surfaced to the SHOPPER at payment time. The\n * merchant, who could have fixed it, was the last to know.\n *\n * `account.updated` is the event that carries this. Stripe sends it on every\n * change to a connected account, which is why the fix is a subscription and\n * not a poll.\n *\n * WHY IT WRITES ONLY WHAT STRIPE ACTUALLY SAID. Both flags are written only\n * when the payload states them as booleans. Coercing an absent field with\n * `Boolean(...)` would manufacture a `false` and lock out a merchant who is\n * fine — the over-restrictive direction, but still a wrong answer invented\n * from no evidence — and defaulting the other way would re-create the very\n * fail-open this closes. An event that states neither flag writes nothing at\n * all; it is not an Account payload.\n *\n * WHY `updateExisting` AND NOT A MERGE-SET. The refs come from a query, so the\n * documents existed a moment ago — but a merge-set would resurrect one erased\n * in between as a stub holding two payout booleans and nothing else, the\n * AGL-1763 phantom shape. `update()` rejects instead, and a rejection here\n * costs nothing: the next `account.updated` re-delivers the same state.\n *\n * IDEMPOTENT AND ORDER-INDEPENDENT by construction — it mirrors current state,\n * never a delta — so a Stripe redelivery and an out-of-order pair both\n * converge.\n *\n * Part of the Stripe Connect payment provider: a plugin reaches it through\n * the contract's `applyAccountEvent`, never by importing it.\n */\nexport interface ConnectAccountStatusEvent {\n /** `event.data.object` for `account.updated` — the Stripe Account. */\n id?: unknown\n charges_enabled?: unknown\n payouts_enabled?: unknown\n}\n\n/**\n * Mirrors a connected account's charge/payout readiness onto whichever\n * documents in `collection` name it in `stripeAccountId`.\n *\n * @param eventLivemode `event.livemode` from the enclosing `account.updated`\n * event (AGL-2471) — NOT `event.data.object.livemode`, which Stripe's\n * Account object does not have. Anything other than a boolean writes\n * nothing, leaving the field absent, which every money gate reads as\n * unverified and refuses.\n * @param collection Firestore collection holding the payout binding —\n * `profiles` for storefront merchants, `publisherProfiles` for marketplace\n * publishers. Each plugin syncs its own; a non-matching account updates\n * nothing.\n * @returns the number of documents updated. 0 is the ordinary answer for an\n * account this collection does not bind, and the caller should not treat it\n * as a failure.\n */\nexport async function syncConnectAccountStatus(\n collection: string,\n account: ConnectAccountStatusEvent | null | undefined,\n eventLivemode?: unknown,\n): Promise<number> {\n const accountId = typeof account?.id === 'string' ? account.id.trim() : ''\n if (!accountId) return 0\n const patch: Record<string, boolean> = {}\n if (typeof account?.charges_enabled === 'boolean') {\n patch['stripeChargesEnabled'] = account.charges_enabled\n }\n if (typeof account?.payouts_enabled === 'boolean') {\n patch['stripePayoutsEnabled'] = account.payouts_enabled\n }\n // WHICH STRIPE WORLD (AGL-2471). The Account object has no `livemode`, but\n // the EVENT carrying it does, and it is Stripe's own statement about the\n // account being described — the strongest evidence available, and it\n // arrives on every change. This is what heals a linkage whose mode was\n // never recorded, without anyone touching the database by hand.\n //\n // Same doctrine as the two flags above: only a literal boolean is written.\n if (typeof eventLivemode === 'boolean') {\n patch['stripeAccountLivemode'] = eventLivemode\n }\n // Nothing stated, nothing written — see the doc comment.\n if (!Object.keys(patch).length) return 0\n\n const matches = await firebaseAdmin\n .app()\n .firestore()\n .collection(collection)\n .where('stripeAccountId', '==', accountId)\n .get()\n let updated = 0\n for (const doc of matches.docs) {\n // THE ACTIVATION TRANSITION, read BEFORE the write (AGL-1580).\n //\n // `stripe_connected` is one of the launch key events, and its two\n // client-side emitters are gated on the merchant's profile still reading\n // \"not connected\" when they click. This webhook is what makes that false:\n // it lands while the merchant is still on Stripe's hosted onboarding, so\n // the flag has already flipped by the time they get back and the browser\n // event can never fire. See `sendGa4StripeConnected` for why the two\n // guards cannot both open for the same account.\n //\n // `!== true` and not `=== false`: a profile that has never been asked has\n // the field ABSENT, and that is the commonest first-connect shape of all.\n const becameConnected =\n patch['stripeChargesEnabled'] === true &&\n doc.get('stripeChargesEnabled') !== true\n // `updateExisting` rethrows anything that is not \"the document is gone\",\n // so a permission or transport failure still reaches the webhook and gets\n // the redelivery it needs.\n if (await updateExisting(doc.ref, patch)) {\n updated += 1\n // Reported only after the write LANDED. A document erased between the\n // query and the update is not an activation, and reporting one would\n // put a merchant in the metric who no longer exists.\n //\n // Awaited, not floated: this runs inside the Stripe webhook's claim on\n // the event, and a floated promise in a serverless handler is frozen at\n // response and never runs — the AGL-2346 shape. The sender swallows\n // everything and cannot un-claim the event by throwing.\n if (becameConnected) await sendGa4StripeConnected({ accountId })\n }\n }\n return updated\n}\n"],"names":["firebaseAdmin","sendGa4StripeConnected","updateExisting","syncConnectAccountStatus","collection","account","eventLivemode","accountId","id","trim","patch","charges_enabled","payouts_enabled","Object","keys","length","matches","app","firestore","where","get","updated","doc","docs","becameConnected","ref"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,sBAAkB;AAChD,SAASC,sBAAsB,QAAQ,gCAA4B;AACnE,SAASC,cAAc,QAAQ,uBAAmB;AA8ClD;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,eAAeC,yBACpBC,UAAkB,EAClBC,OAAqD,EACrDC,aAAuB;IAEvB,MAAMC,YAAY,QAAOF,2BAAAA,QAASG,EAAE,MAAK,WAAWH,QAAQG,EAAE,CAACC,IAAI,KAAK;IACxE,IAAI,CAACF,WAAW,OAAO;IACvB,MAAMG,QAAiC,CAAC;IACxC,IAAI,QAAOL,2BAAAA,QAASM,eAAe,MAAK,WAAW;QACjDD,KAAK,CAAC,uBAAuB,GAAGL,QAAQM,eAAe;IACzD;IACA,IAAI,QAAON,2BAAAA,QAASO,eAAe,MAAK,WAAW;QACjDF,KAAK,CAAC,uBAAuB,GAAGL,QAAQO,eAAe;IACzD;IACA,2EAA2E;IAC3E,yEAAyE;IACzE,qEAAqE;IACrE,uEAAuE;IACvE,gEAAgE;IAChE,EAAE;IACF,2EAA2E;IAC3E,IAAI,OAAON,kBAAkB,WAAW;QACtCI,KAAK,CAAC,wBAAwB,GAAGJ;IACnC;IACA,yDAAyD;IACzD,IAAI,CAACO,OAAOC,IAAI,CAACJ,OAAOK,MAAM,EAAE,OAAO;IAEvC,MAAMC,UAAU,MAAMhB,cACnBiB,GAAG,GACHC,SAAS,GACTd,UAAU,CAACA,YACXe,KAAK,CAAC,mBAAmB,MAAMZ,WAC/Ba,GAAG;IACN,IAAIC,UAAU;IACd,KAAK,MAAMC,OAAON,QAAQO,IAAI,CAAE;QAC9B,+DAA+D;QAC/D,EAAE;QACF,kEAAkE;QAClE,yEAAyE;QACzE,0EAA0E;QAC1E,yEAAyE;QACzE,yEAAyE;QACzE,qEAAqE;QACrE,gDAAgD;QAChD,EAAE;QACF,0EAA0E;QAC1E,0EAA0E;QAC1E,MAAMC,kBACJd,KAAK,CAAC,uBAAuB,KAAK,QAClCY,IAAIF,GAAG,CAAC,4BAA4B;QACtC,yEAAyE;QACzE,0EAA0E;QAC1E,2BAA2B;QAC3B,IAAI,MAAMlB,eAAeoB,IAAIG,GAAG,EAAEf,QAAQ;YACxCW,WAAW;YACX,sEAAsE;YACtE,qEAAqE;YACrE,qDAAqD;YACrD,EAAE;YACF,uEAAuE;YACvE,wEAAwE;YACxE,oEAAoE;YACpE,wDAAwD;YACxD,IAAIG,iBAAiB,MAAMvB,uBAAuB;gBAAEM;YAAU;QAChE;IACF;IACA,OAAOc;AACT"}
|
|
@@ -14,8 +14,19 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { PaymentMode, PaymentProvider, PaymentProviderEvent } from './payment-provider';
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
+
* The Stripe Connect adapter behind the payment-provider contract
|
|
20
|
+
* (`payment-provider.ts`): a tenant's merchant account is a Stripe Connect
|
|
21
|
+
* account, charged and paid out through the platform's Stripe key.
|
|
22
|
+
*
|
|
23
|
+
* This module is the part that is Stripe's — which mode a key states, how the
|
|
24
|
+
* API is asked, which webhook events describe a connected account and where
|
|
25
|
+
* each one keeps the account id. What an event WRITES lives in the two modules
|
|
26
|
+
* it dispatches to, loaded only when such an event arrives, so the money doors
|
|
27
|
+
* that import the contract for its readiness rule load no Firebase.
|
|
28
|
+
*
|
|
29
|
+
* ## Whether a stored Connect linkage can actually take money HERE (AGL-2471)
|
|
19
30
|
*
|
|
20
31
|
* THE DEFECT. Production Firestore held three Connect linkages and all three
|
|
21
32
|
* named TEST-mode accounts. One — `profiles/7AVEMtDa…`, `stripeAccountId:
|
|
@@ -50,20 +61,10 @@
|
|
|
50
61
|
* (above), so the account's mode IS the key's mode; the key's mode is
|
|
51
62
|
* then confirmed against the API via `resolvePlatformStripeMode`.
|
|
52
63
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* `account.updated`. Everywhere else — a test-key deployment, a developer
|
|
58
|
-
* machine, a self-hosted install still in sandbox, the whole test suite — an
|
|
59
|
-
* unverified linkage keeps the behaviour it had before, because no real money
|
|
60
|
-
* can move there and Stripe enforces the mode boundary itself. Refusing
|
|
61
|
-
* everywhere would have bought production nothing extra and broken every
|
|
62
|
-
* non-production deployment.
|
|
63
|
-
*
|
|
64
|
-
* A PROVEN mismatch (`stripeAccountLivemode` recorded and disagreeing) is
|
|
65
|
-
* refused in BOTH directions, because that costs nothing to detect and is
|
|
66
|
-
* never right.
|
|
64
|
+
* The profile field is `stripeAccountLivemode`, and the rule that reads it —
|
|
65
|
+
* refused on a live deployment when absent, refused in both directions when
|
|
66
|
+
* it disagrees — is the contract's `merchantAccountReadiness`, which says why
|
|
67
|
+
* it is asymmetric.
|
|
67
68
|
*
|
|
68
69
|
* WHAT IS SNIFFED, AND WHY THAT ONE IS FAIR. The SECRET KEY states its own
|
|
69
70
|
* mode — `sk_live_`, `sk_test_`, `rk_live_`, `rk_test_` — and that is a
|
|
@@ -80,40 +81,6 @@
|
|
|
80
81
|
* change — the split key is the right call — so the gate has to be the thing
|
|
81
82
|
* that notices.
|
|
82
83
|
*/
|
|
83
|
-
/** Which Stripe world a key or an account belongs to. */
|
|
84
|
-
export type StripeMode = 'live' | 'test';
|
|
85
|
-
/**
|
|
86
|
-
* Why a stored linkage may not be charged against. Every value other than
|
|
87
|
-
* `'ready'` is a refusal, and the mode ones are refusals the old two-field
|
|
88
|
-
* test could not make.
|
|
89
|
-
*/
|
|
90
|
-
export type ConnectReadiness =
|
|
91
|
-
/** Charge away. */
|
|
92
|
-
'ready'
|
|
93
|
-
/** No Connect account is stored at all — the onboarding call to action. */
|
|
94
|
-
| 'not-connected'
|
|
95
|
-
/** Stripe will not let this account take charges. */
|
|
96
|
-
| 'charges-disabled'
|
|
97
|
-
/**
|
|
98
|
-
* The linkage predates AGL-2471 (or was written by something that did not
|
|
99
|
-
* record mode), so its mode was never established — AND this deployment is
|
|
100
|
-
* LIVE. This is the shape all three poisoned production records have.
|
|
101
|
-
* The connect route and the `account.updated` sync both write the field, so
|
|
102
|
-
* a genuine merchant self-heals on the next of either.
|
|
103
|
-
*/
|
|
104
|
-
| 'mode-unverified'
|
|
105
|
-
/** The account belongs to the OTHER Stripe world. The AGL-2471 defect. */
|
|
106
|
-
| 'mode-mismatch';
|
|
107
|
-
export interface ConnectReadinessInput {
|
|
108
|
-
/** `stripeAccountId` off the profile, whatever shape the document has. */
|
|
109
|
-
accountId?: unknown;
|
|
110
|
-
/** `stripeChargesEnabled` off the profile. */
|
|
111
|
-
chargesEnabled?: unknown;
|
|
112
|
-
/** `stripeAccountLivemode` off the profile — absent before AGL-2471. */
|
|
113
|
-
accountLivemode?: unknown;
|
|
114
|
-
/** Defaults to the mode of `STRIPE_SECRET_KEY`. */
|
|
115
|
-
platformMode?: StripeMode | undefined;
|
|
116
|
-
}
|
|
117
84
|
/**
|
|
118
85
|
* The mode a Stripe secret key states about itself, or `undefined` when the
|
|
119
86
|
* string is not a Stripe secret key.
|
|
@@ -122,7 +89,7 @@ export interface ConnectReadinessInput {
|
|
|
122
89
|
* refuses everything else rather than guessing. Handing it an `acct_…` id
|
|
123
90
|
* returns `undefined` — the whole point is that account ids say nothing.
|
|
124
91
|
*/
|
|
125
|
-
export declare function platformStripeMode(key?: string | undefined):
|
|
92
|
+
export declare function platformStripeMode(key?: string | undefined): PaymentMode | undefined;
|
|
126
93
|
/**
|
|
127
94
|
* The platform's mode, asked of Stripe and only then of the key.
|
|
128
95
|
*
|
|
@@ -132,25 +99,33 @@ export declare function platformStripeMode(key?: string | undefined): StripeMode
|
|
|
132
99
|
* balance; without it, a self-hosted install with a scoped key could not
|
|
133
100
|
* finish onboarding at all.
|
|
134
101
|
*/
|
|
135
|
-
export declare function resolvePlatformStripeMode(key?: string | undefined, fetchImpl?: typeof fetch): Promise<
|
|
102
|
+
export declare function resolvePlatformStripeMode(key?: string | undefined, fetchImpl?: typeof fetch): Promise<PaymentMode | undefined>;
|
|
136
103
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
104
|
+
* A Stripe webhook event about a connected account, applied to the profiles in
|
|
105
|
+
* `collection` that store it — the contract's `applyAccountEvent`.
|
|
106
|
+
*
|
|
107
|
+
* Three events describe a connected account, and each keeps the account id
|
|
108
|
+
* somewhere different:
|
|
109
|
+
*
|
|
110
|
+
* - `account.updated` — its readiness changed (AGL-1997). The object IS the
|
|
111
|
+
* Account, and the EVENT carries `livemode` (AGL-2471): the Account object
|
|
112
|
+
* has no such field, and the event's is what lets a linkage whose mode was
|
|
113
|
+
* never recorded heal itself instead of staying refused forever.
|
|
114
|
+
* - `payout.failed` / `transfer.failed` — money headed for the account never
|
|
115
|
+
* landed. A payout is the connected account's balance failing to reach its
|
|
116
|
+
* bank, so its account id is `event.account`: the Payout's own
|
|
117
|
+
* `destination` names the BANK. A transfer is the platform's balance
|
|
118
|
+
* failing to reach the connected account, a platform event whose
|
|
119
|
+
* `destination` IS the account. Recorded and surfaced, never retried:
|
|
120
|
+
* Stripe runs its own retry schedule, and a second transfer against an
|
|
121
|
+
* account that has just refused one is how a duplicate lands.
|
|
122
|
+
* - `payout.paid` — a later payout landed, which retires the warning the
|
|
123
|
+
* failure left on the profile. The history is kept.
|
|
124
|
+
*
|
|
125
|
+
* Every other event answers `false` without loading anything. A write that
|
|
126
|
+
* fails throws, and the webhook's 500 earns the redelivery; every write
|
|
127
|
+
* mirrors current state or is keyed by Stripe's id, so a redelivery converges.
|
|
153
128
|
*/
|
|
154
|
-
export declare function
|
|
155
|
-
|
|
156
|
-
|
|
129
|
+
export declare function applyStripeConnectAccountEvent(collection: string, delivered: PaymentProviderEvent): Promise<boolean>;
|
|
130
|
+
/** The adapter the contract returns. */
|
|
131
|
+
export declare const STRIPE_CONNECT_PAYMENT_PROVIDER: PaymentProvider;
|
|
@@ -14,7 +14,17 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ /**
|
|
17
|
-
*
|
|
17
|
+
* The Stripe Connect adapter behind the payment-provider contract
|
|
18
|
+
* (`payment-provider.ts`): a tenant's merchant account is a Stripe Connect
|
|
19
|
+
* account, charged and paid out through the platform's Stripe key.
|
|
20
|
+
*
|
|
21
|
+
* This module is the part that is Stripe's — which mode a key states, how the
|
|
22
|
+
* API is asked, which webhook events describe a connected account and where
|
|
23
|
+
* each one keeps the account id. What an event WRITES lives in the two modules
|
|
24
|
+
* it dispatches to, loaded only when such an event arrives, so the money doors
|
|
25
|
+
* that import the contract for its readiness rule load no Firebase.
|
|
26
|
+
*
|
|
27
|
+
* ## Whether a stored Connect linkage can actually take money HERE (AGL-2471)
|
|
18
28
|
*
|
|
19
29
|
* THE DEFECT. Production Firestore held three Connect linkages and all three
|
|
20
30
|
* named TEST-mode accounts. One — `profiles/7AVEMtDa…`, `stripeAccountId:
|
|
@@ -49,20 +59,10 @@
|
|
|
49
59
|
* (above), so the account's mode IS the key's mode; the key's mode is
|
|
50
60
|
* then confirmed against the API via `resolvePlatformStripeMode`.
|
|
51
61
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* `account.updated`. Everywhere else — a test-key deployment, a developer
|
|
57
|
-
* machine, a self-hosted install still in sandbox, the whole test suite — an
|
|
58
|
-
* unverified linkage keeps the behaviour it had before, because no real money
|
|
59
|
-
* can move there and Stripe enforces the mode boundary itself. Refusing
|
|
60
|
-
* everywhere would have bought production nothing extra and broken every
|
|
61
|
-
* non-production deployment.
|
|
62
|
-
*
|
|
63
|
-
* A PROVEN mismatch (`stripeAccountLivemode` recorded and disagreeing) is
|
|
64
|
-
* refused in BOTH directions, because that costs nothing to detect and is
|
|
65
|
-
* never right.
|
|
62
|
+
* The profile field is `stripeAccountLivemode`, and the rule that reads it —
|
|
63
|
+
* refused on a live deployment when absent, refused in both directions when
|
|
64
|
+
* it disagrees — is the contract's `merchantAccountReadiness`, which says why
|
|
65
|
+
* it is asymmetric.
|
|
66
66
|
*
|
|
67
67
|
* WHAT IS SNIFFED, AND WHY THAT ONE IS FAIR. The SECRET KEY states its own
|
|
68
68
|
* mode — `sk_live_`, `sk_test_`, `rk_live_`, `rk_test_` — and that is a
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
* and writes it into the production database. Nothing about that is going to
|
|
79
79
|
* change — the split key is the right call — so the gate has to be the thing
|
|
80
80
|
* that notices.
|
|
81
|
-
*/ /**
|
|
81
|
+
*/ /**
|
|
82
82
|
* The mode a Stripe secret key states about itself, or `undefined` when the
|
|
83
83
|
* string is not a Stripe secret key.
|
|
84
84
|
*
|
|
@@ -120,43 +120,69 @@
|
|
|
120
120
|
return fromKey;
|
|
121
121
|
}
|
|
122
122
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
123
|
+
* A Stripe webhook event about a connected account, applied to the profiles in
|
|
124
|
+
* `collection` that store it — the contract's `applyAccountEvent`.
|
|
125
|
+
*
|
|
126
|
+
* Three events describe a connected account, and each keeps the account id
|
|
127
|
+
* somewhere different:
|
|
128
|
+
*
|
|
129
|
+
* - `account.updated` — its readiness changed (AGL-1997). The object IS the
|
|
130
|
+
* Account, and the EVENT carries `livemode` (AGL-2471): the Account object
|
|
131
|
+
* has no such field, and the event's is what lets a linkage whose mode was
|
|
132
|
+
* never recorded heal itself instead of staying refused forever.
|
|
133
|
+
* - `payout.failed` / `transfer.failed` — money headed for the account never
|
|
134
|
+
* landed. A payout is the connected account's balance failing to reach its
|
|
135
|
+
* bank, so its account id is `event.account`: the Payout's own
|
|
136
|
+
* `destination` names the BANK. A transfer is the platform's balance
|
|
137
|
+
* failing to reach the connected account, a platform event whose
|
|
138
|
+
* `destination` IS the account. Recorded and surfaced, never retried:
|
|
139
|
+
* Stripe runs its own retry schedule, and a second transfer against an
|
|
140
|
+
* account that has just refused one is how a duplicate lands.
|
|
141
|
+
* - `payout.paid` — a later payout landed, which retires the warning the
|
|
142
|
+
* failure left on the profile. The history is kept.
|
|
143
|
+
*
|
|
144
|
+
* Every other event answers `false` without loading anything. A write that
|
|
145
|
+
* fails throws, and the webhook's 500 earns the redelivery; every write
|
|
146
|
+
* mirrors current state or is keyed by Stripe's id, so a redelivery converges.
|
|
147
|
+
*/ export async function applyStripeConnectAccountEvent(collection, delivered) {
|
|
148
|
+
const type = delivered == null ? void 0 : delivered.type;
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
150
|
+
const object = delivered == null ? void 0 : delivered.object;
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
|
+
const event = delivered == null ? void 0 : delivered.event;
|
|
153
|
+
if (type === 'account.updated') {
|
|
154
|
+
const { syncConnectAccountStatus } = await import("./payment-provider-stripe-connect-status.js");
|
|
155
|
+
await syncConnectAccountStatus(collection, object, event == null ? void 0 : event.livemode);
|
|
156
|
+
return true;
|
|
143
157
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
if (type === 'payout.failed' || type === 'transfer.failed') {
|
|
159
|
+
var _ref, _ref1, _ref2;
|
|
160
|
+
var _object_destination;
|
|
161
|
+
const failedAccountId = type === 'payout.failed' ? String((_ref = event == null ? void 0 : event.account) != null ? _ref : '') : String((_ref1 = (_ref2 = object == null ? void 0 : (_object_destination = object.destination) == null ? void 0 : _object_destination.id) != null ? _ref2 : object == null ? void 0 : object.destination) != null ? _ref1 : '');
|
|
162
|
+
const { recordConnectPayoutFailure } = await import("./payment-provider-stripe-connect-payouts.js");
|
|
163
|
+
await recordConnectPayoutFailure(collection, {
|
|
164
|
+
kind: type === 'payout.failed' ? 'payout' : 'transfer',
|
|
165
|
+
object,
|
|
166
|
+
accountId: failedAccountId,
|
|
167
|
+
livemode: event == null ? void 0 : event.livemode
|
|
168
|
+
});
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (type === 'payout.paid') {
|
|
172
|
+
var _ref3;
|
|
173
|
+
const { clearConnectPayoutFailure } = await import("./payment-provider-stripe-connect-payouts.js");
|
|
174
|
+
await clearConnectPayoutFailure(collection, String((_ref3 = event == null ? void 0 : event.account) != null ? _ref3 : ''));
|
|
175
|
+
return true;
|
|
158
176
|
}
|
|
159
|
-
return
|
|
177
|
+
return false;
|
|
160
178
|
}
|
|
179
|
+
/** The adapter the contract returns. */ export const STRIPE_CONNECT_PAYMENT_PROVIDER = {
|
|
180
|
+
id: 'stripe-connect',
|
|
181
|
+
// Called with no argument, so the key is read when asked — not when this
|
|
182
|
+
// module was first evaluated.
|
|
183
|
+
platformMode: ()=>platformStripeMode(),
|
|
184
|
+
resolvePlatformMode: ()=>resolvePlatformStripeMode(),
|
|
185
|
+
applyAccountEvent: applyStripeConnectAccountEvent
|
|
186
|
+
};
|
|
161
187
|
|
|
162
|
-
//# sourceMappingURL=stripe-
|
|
188
|
+
//# sourceMappingURL=payment-provider-stripe-connect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-provider-stripe-connect.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PaymentMode, PaymentProvider, PaymentProviderEvent } from './payment-provider'\n\n/**\n * The Stripe Connect adapter behind the payment-provider contract\n * (`payment-provider.ts`): a tenant's merchant account is a Stripe Connect\n * account, charged and paid out through the platform's Stripe key.\n *\n * This module is the part that is Stripe's — which mode a key states, how the\n * API is asked, which webhook events describe a connected account and where\n * each one keeps the account id. What an event WRITES lives in the two modules\n * it dispatches to, loaded only when such an event arrives, so the money doors\n * that import the contract for its readiness rule load no Firebase.\n *\n * ## Whether a stored Connect linkage can actually take money HERE (AGL-2471)\n *\n * THE DEFECT. Production Firestore held three Connect linkages and all three\n * named TEST-mode accounts. One — `profiles/7AVEMtDa…`, `stripeAccountId:\n * acct_1TulDeRbL3B9Ioqz` — also carried `stripeChargesEnabled: true`, which\n * was the entire payments-readiness test every money door made:\n *\n * if (!accountId || !ownerProfile.get('stripeChargesEnabled')) …\n *\n * So three storefronts presented as payments-ready, minted a LIVE Checkout\n * session with a TEST-mode `payment_intent_data[transfer_data][destination]`,\n * and were refused by Stripe as a generic 502 at the shopper's checkout. A\n * merchant told they are ready to take money, who silently cannot.\n *\n * WHY THE ACCOUNT ID IS NOT THE EVIDENCE. `acct_1TulDeRbL3B9Ioqz` is a real\n * production value naming a TEST account. Stripe's account ids carry no mode\n * marker — live and test ids are the same shape — so no amount of reading the\n * string can tell them apart. What DOES tell them apart is Stripe itself:\n * retrieving that id with the live key answers\n *\n * 400 The account acct_1TulDeRbL3B9Ioqz was a test account created with a\n * testmode key, and therefore can only be used with testmode keys.\n *\n * The Account object, notably, has NO `livemode` field of its own (verified\n * against the live API: the payload carries `charges_enabled`,\n * `payouts_enabled`, `capabilities` and no `livemode`). So the mode is\n * recorded at the two moments Stripe does state it:\n *\n * 1. `account.updated` — the EVENT carries `livemode`, and it is Stripe's\n * own statement about the account the event is describing;\n * 2. onboarding — the account was just created or retrieved successfully\n * with the platform key, and Stripe hard-refuses cross-mode retrieval\n * (above), so the account's mode IS the key's mode; the key's mode is\n * then confirmed against the API via `resolvePlatformStripeMode`.\n *\n * The profile field is `stripeAccountLivemode`, and the rule that reads it —\n * refused on a live deployment when absent, refused in both directions when\n * it disagrees — is the contract's `merchantAccountReadiness`, which says why\n * it is asymmetric.\n *\n * WHAT IS SNIFFED, AND WHY THAT ONE IS FAIR. The SECRET KEY states its own\n * mode — `sk_live_`, `sk_test_`, `rk_live_`, `rk_test_` — and that is a\n * documented, stable Stripe invariant, unlike the account id. It is used only\n * to answer \"which mode is this deployment\", never \"which mode is this\n * account\", and even there `resolvePlatformStripeMode` prefers the API.\n *\n * HOW IT GOT THERE. A developer machine runs against PRODUCTION Firestore\n * (`FIREBASE_PROJECT_ID=aglyn-main` in every dev env file) while\n * `apps/console/.env.development.local` sets `STRIPE_SECRET_KEY` to the\n * `sk_test_` key by deliberate policy (AGL-1137: localhost must never touch\n * live Stripe). The connect route then creates a test-mode Express account\n * and writes it into the production database. Nothing about that is going to\n * change — the split key is the right call — so the gate has to be the thing\n * that notices.\n */\n\n/**\n * The mode a Stripe secret key states about itself, or `undefined` when the\n * string is not a Stripe secret key.\n *\n * Deliberately narrow: it matches `sk_`/`rk_` followed by `live`/`test`, and\n * refuses everything else rather than guessing. Handing it an `acct_…` id\n * returns `undefined` — the whole point is that account ids say nothing.\n */\nexport function platformStripeMode(\n key: string | undefined = process.env.STRIPE_SECRET_KEY,\n): PaymentMode | undefined {\n const match = /^[sr]k_(live|test)_/.exec(String(key ?? '').trim())\n return match ? (match[1] as PaymentMode) : undefined\n}\n\n/**\n * The platform's mode, asked of Stripe and only then of the key.\n *\n * `GET /v1/balance` is a singleton that carries `livemode`, so it states the\n * mode of whatever key made the call — no object has to be created to find\n * out. The key prefix is the fallback for a restricted key that cannot read\n * balance; without it, a self-hosted install with a scoped key could not\n * finish onboarding at all.\n */\nexport async function resolvePlatformStripeMode(\n key: string | undefined = process.env.STRIPE_SECRET_KEY,\n fetchImpl: typeof fetch = fetch,\n): Promise<PaymentMode | undefined> {\n const fromKey = platformStripeMode(key)\n if (!key) return fromKey\n try {\n const response = await fetchImpl('https://api.stripe.com/v1/balance', {\n method: 'GET',\n headers: { Authorization: `Bearer ${key}` },\n })\n if (response.ok) {\n const payload = await response.json()\n if (typeof payload?.livemode === 'boolean') {\n return payload.livemode ? 'live' : 'test'\n }\n }\n } catch {\n // Network or transport failure — fall through to the key, which is the\n // same answer we would have recorded before this existed.\n }\n return fromKey\n}\n\n/**\n * A Stripe webhook event about a connected account, applied to the profiles in\n * `collection` that store it — the contract's `applyAccountEvent`.\n *\n * Three events describe a connected account, and each keeps the account id\n * somewhere different:\n *\n * - `account.updated` — its readiness changed (AGL-1997). The object IS the\n * Account, and the EVENT carries `livemode` (AGL-2471): the Account object\n * has no such field, and the event's is what lets a linkage whose mode was\n * never recorded heal itself instead of staying refused forever.\n * - `payout.failed` / `transfer.failed` — money headed for the account never\n * landed. A payout is the connected account's balance failing to reach its\n * bank, so its account id is `event.account`: the Payout's own\n * `destination` names the BANK. A transfer is the platform's balance\n * failing to reach the connected account, a platform event whose\n * `destination` IS the account. Recorded and surfaced, never retried:\n * Stripe runs its own retry schedule, and a second transfer against an\n * account that has just refused one is how a duplicate lands.\n * - `payout.paid` — a later payout landed, which retires the warning the\n * failure left on the profile. The history is kept.\n *\n * Every other event answers `false` without loading anything. A write that\n * fails throws, and the webhook's 500 earns the redelivery; every write\n * mirrors current state or is keyed by Stripe's id, so a redelivery converges.\n */\nexport async function applyStripeConnectAccountEvent(\n collection: string,\n delivered: PaymentProviderEvent,\n): Promise<boolean> {\n const type = delivered?.type\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const object = delivered?.object as any\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const event = delivered?.event as any\n if (type === 'account.updated') {\n const { syncConnectAccountStatus } = await import(\n './payment-provider-stripe-connect-status'\n )\n await syncConnectAccountStatus(collection, object, event?.livemode)\n return true\n }\n if (type === 'payout.failed' || type === 'transfer.failed') {\n const failedAccountId =\n type === 'payout.failed'\n ? String(event?.account ?? '')\n : String(object?.destination?.id ?? object?.destination ?? '')\n const { recordConnectPayoutFailure } = await import(\n './payment-provider-stripe-connect-payouts'\n )\n await recordConnectPayoutFailure(collection, {\n kind: type === 'payout.failed' ? 'payout' : 'transfer',\n object,\n accountId: failedAccountId,\n livemode: event?.livemode,\n })\n return true\n }\n if (type === 'payout.paid') {\n const { clearConnectPayoutFailure } = await import(\n './payment-provider-stripe-connect-payouts'\n )\n await clearConnectPayoutFailure(collection, String(event?.account ?? ''))\n return true\n }\n return false\n}\n\n/** The adapter the contract returns. */\nexport const STRIPE_CONNECT_PAYMENT_PROVIDER: PaymentProvider = {\n id: 'stripe-connect',\n // Called with no argument, so the key is read when asked — not when this\n // module was first evaluated.\n platformMode: () => platformStripeMode(),\n resolvePlatformMode: () => resolvePlatformStripeMode(),\n applyAccountEvent: applyStripeConnectAccountEvent,\n}\n"],"names":["platformStripeMode","key","process","env","STRIPE_SECRET_KEY","match","exec","String","trim","undefined","resolvePlatformStripeMode","fetchImpl","fetch","fromKey","response","method","headers","Authorization","ok","payload","json","livemode","applyStripeConnectAccountEvent","collection","delivered","type","object","event","syncConnectAccountStatus","failedAccountId","account","destination","id","recordConnectPayoutFailure","kind","accountId","clearConnectPayoutFailure","STRIPE_CONNECT_PAYMENT_PROVIDER","platformMode","resolvePlatformMode","applyAccountEvent"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiEC,GAED;;;;;;;CAOC,GACD,OAAO,SAASA,mBACdC,MAA0BC,QAAQC,GAAG,CAACC,iBAAiB;IAEvD,MAAMC,QAAQ,sBAAsBC,IAAI,CAACC,OAAON,cAAAA,MAAO,IAAIO,IAAI;IAC/D,OAAOH,QAASA,KAAK,CAAC,EAAE,GAAmBI;AAC7C;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeC,0BACpBT,MAA0BC,QAAQC,GAAG,CAACC,iBAAiB,EACvDO,YAA0BC,KAAK;IAE/B,MAAMC,UAAUb,mBAAmBC;IACnC,IAAI,CAACA,KAAK,OAAOY;IACjB,IAAI;QACF,MAAMC,WAAW,MAAMH,UAAU,qCAAqC;YACpEI,QAAQ;YACRC,SAAS;gBAAEC,eAAe,CAAC,OAAO,EAAEhB,KAAK;YAAC;QAC5C;QACA,IAAIa,SAASI,EAAE,EAAE;YACf,MAAMC,UAAU,MAAML,SAASM,IAAI;YACnC,IAAI,QAAOD,2BAAAA,QAASE,QAAQ,MAAK,WAAW;gBAC1C,OAAOF,QAAQE,QAAQ,GAAG,SAAS;YACrC;QACF;IACF,EAAE,eAAM;IACN,uEAAuE;IACvE,0DAA0D;IAC5D;IACA,OAAOR;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,eAAeS,+BACpBC,UAAkB,EAClBC,SAA+B;IAE/B,MAAMC,OAAOD,6BAAAA,UAAWC,IAAI;IAC5B,8DAA8D;IAC9D,MAAMC,SAASF,6BAAAA,UAAWE,MAAM;IAChC,8DAA8D;IAC9D,MAAMC,QAAQH,6BAAAA,UAAWG,KAAK;IAC9B,IAAIF,SAAS,mBAAmB;QAC9B,MAAM,EAAEG,wBAAwB,EAAE,GAAG,MAAM,MAAM,CAC/C;QAEF,MAAMA,yBAAyBL,YAAYG,QAAQC,yBAAAA,MAAON,QAAQ;QAClE,OAAO;IACT;IACA,IAAII,SAAS,mBAAmBA,SAAS,mBAAmB;kBAI7CC;YAAAA;QAHb,MAAMG,kBACJJ,SAAS,kBACLlB,eAAOoB,yBAAAA,MAAOG,OAAO,mBAAI,MACzBvB,QAAOmB,iBAAAA,2BAAAA,sBAAAA,OAAQK,WAAW,qBAAnBL,oBAAqBM,EAAE,oBAAIN,0BAAAA,OAAQK,WAAW,YAA9CL,QAAkD;QAC/D,MAAM,EAAEO,0BAA0B,EAAE,GAAG,MAAM,MAAM,CACjD;QAEF,MAAMA,2BAA2BV,YAAY;YAC3CW,MAAMT,SAAS,kBAAkB,WAAW;YAC5CC;YACAS,WAAWN;YACXR,QAAQ,EAAEM,yBAAAA,MAAON,QAAQ;QAC3B;QACA,OAAO;IACT;IACA,IAAII,SAAS,eAAe;;QAC1B,MAAM,EAAEW,yBAAyB,EAAE,GAAG,MAAM,MAAM,CAChD;QAEF,MAAMA,0BAA0Bb,YAAYhB,gBAAOoB,yBAAAA,MAAOG,OAAO,oBAAI;QACrE,OAAO;IACT;IACA,OAAO;AACT;AAEA,sCAAsC,GACtC,OAAO,MAAMO,kCAAmD;IAC9DL,IAAI;IACJ,yEAAyE;IACzE,8BAA8B;IAC9BM,cAAc,IAAMtC;IACpBuC,qBAAqB,IAAM7B;IAC3B8B,mBAAmBlB;AACrB,EAAC"}
|