@cosmicdrift/kumiko-bundled-features 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// VerifyEmailScreen — liest `?token=...` aus URL, schickt ihn auto an
|
|
3
|
+
// /api/auth/verify-email beim Mount, zeigt success/error. Idempotent
|
|
4
|
+
// auf Server-Seite (mehrfaches Klicken setzt emailVerified=true erneut),
|
|
5
|
+
// also kein Re-Submit-Risiko.
|
|
6
|
+
//
|
|
7
|
+
// useEffect mit empty-deps + once-Guard damit React Strict-Mode den
|
|
8
|
+
// Verify-Call nur einmal feuert (Strict-Mode dispatched useEffect 2x in
|
|
9
|
+
// dev). Token-roundtrip ist server-side single-use, aber wir wollen
|
|
10
|
+
// nicht den ersten valid-call beim Mount und den zweiten als invalid-
|
|
11
|
+
// Banner sehen.
|
|
12
|
+
|
|
13
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
15
|
+
import { verifyEmail } from "./auth-client";
|
|
16
|
+
import {
|
|
17
|
+
AuthCard,
|
|
18
|
+
authButtonClass,
|
|
19
|
+
authMutedLinkClass,
|
|
20
|
+
parseUrlToken,
|
|
21
|
+
} from "./auth-form-primitives";
|
|
22
|
+
|
|
23
|
+
export type VerifyEmailScreenProps = {
|
|
24
|
+
readonly title?: string;
|
|
25
|
+
/** Override für Token aus URL. Default: parsed aus `?token=...`. */
|
|
26
|
+
readonly token?: string;
|
|
27
|
+
/** href für "Zum Login"-Link. Default "/login". */
|
|
28
|
+
readonly loginHref?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type Status = "verifying" | "success" | "error" | "missing-token";
|
|
32
|
+
|
|
33
|
+
export function VerifyEmailScreen({
|
|
34
|
+
title,
|
|
35
|
+
token: tokenProp,
|
|
36
|
+
loginHref = "/login",
|
|
37
|
+
}: VerifyEmailScreenProps): ReactNode {
|
|
38
|
+
const t = useTranslation();
|
|
39
|
+
const [token] = useState(() => tokenProp ?? parseUrlToken());
|
|
40
|
+
const [status, setStatus] = useState<Status>(token === "" ? "missing-token" : "verifying");
|
|
41
|
+
const startedRef = useRef(false);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (status !== "verifying") return;
|
|
45
|
+
if (startedRef.current) return;
|
|
46
|
+
startedRef.current = true;
|
|
47
|
+
void (async () => {
|
|
48
|
+
const res = await verifyEmail(token);
|
|
49
|
+
setStatus(res.ok ? "success" : "error");
|
|
50
|
+
})();
|
|
51
|
+
}, [status, token]);
|
|
52
|
+
|
|
53
|
+
if (status === "missing-token") {
|
|
54
|
+
return (
|
|
55
|
+
<AuthCard title={title ?? t("auth.verifyEmail.errorTitle")}>
|
|
56
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
57
|
+
<p className="text-sm text-muted-foreground">{t("auth.verifyEmail.missingToken")}</p>
|
|
58
|
+
<a href={loginHref} className={authMutedLinkClass}>
|
|
59
|
+
{t("auth.verifyEmail.goToLogin")}
|
|
60
|
+
</a>
|
|
61
|
+
</div>
|
|
62
|
+
</AuthCard>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (status === "verifying") {
|
|
67
|
+
return (
|
|
68
|
+
<AuthCard>
|
|
69
|
+
<div className="p-6">
|
|
70
|
+
<p className="text-sm text-muted-foreground" role="status">
|
|
71
|
+
{t("auth.verifyEmail.verifying")}
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
</AuthCard>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (status === "success") {
|
|
79
|
+
return (
|
|
80
|
+
<AuthCard title={title ?? t("auth.verifyEmail.successTitle")}>
|
|
81
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
82
|
+
<p className="text-sm text-muted-foreground">{t("auth.verifyEmail.successBody")}</p>
|
|
83
|
+
<a href={loginHref} className={authButtonClass}>
|
|
84
|
+
{t("auth.verifyEmail.goToLogin")}
|
|
85
|
+
</a>
|
|
86
|
+
</div>
|
|
87
|
+
</AuthCard>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// status === "error"
|
|
92
|
+
return (
|
|
93
|
+
<AuthCard title={title ?? t("auth.verifyEmail.errorTitle")}>
|
|
94
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
95
|
+
<p className="text-sm text-muted-foreground">{t("auth.verifyEmail.errorBody")}</p>
|
|
96
|
+
<a href={loginHref} className={authMutedLinkClass}>
|
|
97
|
+
{t("auth.verifyEmail.goToLogin")}
|
|
98
|
+
</a>
|
|
99
|
+
</div>
|
|
100
|
+
</AuthCard>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// Integration-test for subscription-foundation. Treibt den process-
|
|
2
|
+
// event-handler durch den full Dispatcher + DB.
|
|
3
|
+
//
|
|
4
|
+
// **Mock-Plugin-Strategie (analog ai-foundation):** wir testen NICHT
|
|
5
|
+
// die Stripe-/Mollie-spezifischen Plugins (das passiert in deren
|
|
6
|
+
// eigenen feature.test.ts in Phase 5.2/5.3). Hier: direkter ctx.write
|
|
7
|
+
// auf process-event mit normalisiertem SubscriptionEvent als payload —
|
|
8
|
+
// beweist die Foundation-eigene Verdrahtung (atomic insert + upsert,
|
|
9
|
+
// Idempotency via deterministic aggregate-ids, tenant-isolation).
|
|
10
|
+
//
|
|
11
|
+
// Webhook-Handler-Factory (createSubscriptionWebhookHandler) wird in
|
|
12
|
+
// einem separaten Test mit Hono-mock geprüft.
|
|
13
|
+
|
|
14
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
createTestUser,
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
testTenantId,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
24
|
+
import { subscriptionAggregateId } from "../aggregate-id";
|
|
25
|
+
import {
|
|
26
|
+
SubscriptionEventTypes,
|
|
27
|
+
SubscriptionFoundationHandlers,
|
|
28
|
+
SubscriptionStatuses,
|
|
29
|
+
} from "../constants";
|
|
30
|
+
import { billingFoundationFeature } from "../feature";
|
|
31
|
+
import type { SubscriptionProviderPlugin } from "../types";
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// Mock-plugin für create-checkout-session + create-portal-session-Tests.
|
|
35
|
+
// **Pattern-Vorbild:** ai-foundation.integration.ts mit zwei inline-mock-
|
|
36
|
+
// plugins. Vermeidet zweiten beforeAll/setupTestStack — selber stack,
|
|
37
|
+
// einfach extra-feature im features-array.
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
const mockCheckoutCalls: Array<{
|
|
41
|
+
priceId: string;
|
|
42
|
+
tenantId: string;
|
|
43
|
+
successUrl: string;
|
|
44
|
+
cancelUrl: string;
|
|
45
|
+
providerCustomerId?: string;
|
|
46
|
+
}> = [];
|
|
47
|
+
const mockPortalCalls: Array<{ providerCustomerId: string; returnUrl: string }> = [];
|
|
48
|
+
|
|
49
|
+
const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
|
|
50
|
+
r.requires("billing-foundation");
|
|
51
|
+
const plugin: SubscriptionProviderPlugin = {
|
|
52
|
+
verifyAndParseWebhook: async () => null,
|
|
53
|
+
createCheckoutSession: async (_ctx, options) => {
|
|
54
|
+
mockCheckoutCalls.push({
|
|
55
|
+
priceId: options.priceId,
|
|
56
|
+
tenantId: options.tenantId,
|
|
57
|
+
successUrl: options.successUrl,
|
|
58
|
+
cancelUrl: options.cancelUrl,
|
|
59
|
+
...(options.providerCustomerId && { providerCustomerId: options.providerCustomerId }),
|
|
60
|
+
});
|
|
61
|
+
return { url: `https://mock.example/checkout/${options.priceId}` };
|
|
62
|
+
},
|
|
63
|
+
createPortalSession: async (_ctx, options) => {
|
|
64
|
+
mockPortalCalls.push({
|
|
65
|
+
providerCustomerId: options.providerCustomerId,
|
|
66
|
+
returnUrl: options.returnUrl,
|
|
67
|
+
});
|
|
68
|
+
return { url: `https://mock.example/portal/${options.providerCustomerId}` };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
r.useExtension("subscriptionProvider", "mock", plugin);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// Setup
|
|
76
|
+
// =============================================================================
|
|
77
|
+
|
|
78
|
+
let stack: TestStack;
|
|
79
|
+
let db: DbConnection;
|
|
80
|
+
|
|
81
|
+
beforeAll(async () => {
|
|
82
|
+
stack = await setupTestStack({
|
|
83
|
+
features: [billingFoundationFeature, mockProviderFeature],
|
|
84
|
+
});
|
|
85
|
+
db = stack.db;
|
|
86
|
+
// subscriptionsProjectionTable wird von setupTestStack automatisch
|
|
87
|
+
// gepusht (r.projection mit `table`-Property → auto-push).
|
|
88
|
+
await createEventsTable(db);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await stack.cleanup();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function adminFor(tenantNumber: number) {
|
|
96
|
+
return createTestUser({
|
|
97
|
+
id: tenantNumber,
|
|
98
|
+
tenantId: testTenantId(tenantNumber),
|
|
99
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildEvent(
|
|
104
|
+
overrides: Partial<{
|
|
105
|
+
providerEventId: string;
|
|
106
|
+
type: string;
|
|
107
|
+
status: string;
|
|
108
|
+
tier: string;
|
|
109
|
+
providerCustomerId: string;
|
|
110
|
+
providerSubscriptionId: string;
|
|
111
|
+
currentPeriodEndIso: string;
|
|
112
|
+
rawPayload: string;
|
|
113
|
+
}> = {},
|
|
114
|
+
) {
|
|
115
|
+
return {
|
|
116
|
+
providerEventId: overrides.providerEventId ?? "evt_test_default",
|
|
117
|
+
providerName: "stripe",
|
|
118
|
+
type: overrides.type ?? SubscriptionEventTypes.created,
|
|
119
|
+
providerCustomerId: overrides.providerCustomerId ?? "cus_default",
|
|
120
|
+
providerSubscriptionId: overrides.providerSubscriptionId ?? "sub_default",
|
|
121
|
+
status: overrides.status ?? SubscriptionStatuses.active,
|
|
122
|
+
tier: overrides.tier ?? "pro",
|
|
123
|
+
currentPeriodEndIso: overrides.currentPeriodEndIso ?? "2026-06-01T00:00:00Z",
|
|
124
|
+
rawPayload: overrides.rawPayload ?? '{"raw":"payload"}',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// =============================================================================
|
|
129
|
+
// Scenarios
|
|
130
|
+
// =============================================================================
|
|
131
|
+
|
|
132
|
+
describe("scenario 1: webhook-event creates subscription + audit-row", () => {
|
|
133
|
+
test("first event for tenant → subscription-row + subscription-event-row erzeugt", async () => {
|
|
134
|
+
const admin = adminFor(3001);
|
|
135
|
+
const result = (await stack.http.writeOk(
|
|
136
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
137
|
+
buildEvent({
|
|
138
|
+
providerEventId: "evt_3001_create",
|
|
139
|
+
providerCustomerId: "cus_3001",
|
|
140
|
+
providerSubscriptionId: "sub_3001",
|
|
141
|
+
}),
|
|
142
|
+
admin,
|
|
143
|
+
)) as Record<string, unknown>;
|
|
144
|
+
|
|
145
|
+
expect(result["duplicate"]).toBe(false);
|
|
146
|
+
expect(result["subscriptionAggregateId"]).toBe(subscriptionAggregateId(admin.tenantId));
|
|
147
|
+
|
|
148
|
+
// subscription-row sichtbar via list-query
|
|
149
|
+
const subs = (await stack.http.queryOk(
|
|
150
|
+
"billing-foundation:query:subscription:list",
|
|
151
|
+
{},
|
|
152
|
+
admin,
|
|
153
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
154
|
+
expect(subs.rows).toHaveLength(1);
|
|
155
|
+
expect(subs.rows[0]?.["tier"]).toBe("pro");
|
|
156
|
+
expect(subs.rows[0]?.["providerCustomerId"]).toBe("cus_3001");
|
|
157
|
+
|
|
158
|
+
// ES-event archiviert (= audit lebt im event-store, kein separate
|
|
159
|
+
// subscription-event-Tabelle mehr).
|
|
160
|
+
const esEvents = await loadAggregate(
|
|
161
|
+
db,
|
|
162
|
+
subscriptionAggregateId(admin.tenantId),
|
|
163
|
+
admin.tenantId,
|
|
164
|
+
);
|
|
165
|
+
expect(esEvents).toHaveLength(1);
|
|
166
|
+
expect(esEvents[0]?.type).toBe("billing-foundation:event:subscription-created");
|
|
167
|
+
expect(esEvents[0]?.metadata.headers?.["providerEventId"]).toBe("evt_3001_create");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("scenario 2: webhook-update upserts subscription, archiviert weiteren event", () => {
|
|
172
|
+
test("zweiter event für selben Tenant → subscription geupdated, beide events in audit", async () => {
|
|
173
|
+
const admin = adminFor(3002);
|
|
174
|
+
|
|
175
|
+
// create
|
|
176
|
+
await stack.http.writeOk(
|
|
177
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
178
|
+
buildEvent({
|
|
179
|
+
providerEventId: "evt_3002_create",
|
|
180
|
+
providerCustomerId: "cus_3002",
|
|
181
|
+
providerSubscriptionId: "sub_3002",
|
|
182
|
+
tier: "pro",
|
|
183
|
+
}),
|
|
184
|
+
admin,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// update — same subscription, neuer tier
|
|
188
|
+
await stack.http.writeOk(
|
|
189
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
190
|
+
buildEvent({
|
|
191
|
+
providerEventId: "evt_3002_update",
|
|
192
|
+
type: SubscriptionEventTypes.updated,
|
|
193
|
+
providerCustomerId: "cus_3002",
|
|
194
|
+
providerSubscriptionId: "sub_3002",
|
|
195
|
+
tier: "business", // upgrade
|
|
196
|
+
}),
|
|
197
|
+
admin,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const subs = (await stack.http.queryOk(
|
|
201
|
+
"billing-foundation:query:subscription:list",
|
|
202
|
+
{},
|
|
203
|
+
admin,
|
|
204
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
205
|
+
expect(subs.rows).toHaveLength(1); // immer noch 1 row, geupdated
|
|
206
|
+
expect(subs.rows[0]?.["tier"]).toBe("business");
|
|
207
|
+
|
|
208
|
+
const esEvents = await loadAggregate(
|
|
209
|
+
db,
|
|
210
|
+
subscriptionAggregateId(admin.tenantId),
|
|
211
|
+
admin.tenantId,
|
|
212
|
+
);
|
|
213
|
+
expect(esEvents).toHaveLength(2); // create + update beide im stream
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("scenario 3: idempotency — webhook-retry mit selber providerEventId", () => {
|
|
218
|
+
test("zweiter call mit gleichem providerEventId → duplicate=true, kein zweiter event-row", async () => {
|
|
219
|
+
const admin = adminFor(3003);
|
|
220
|
+
|
|
221
|
+
const first = (await stack.http.writeOk(
|
|
222
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
223
|
+
buildEvent({
|
|
224
|
+
providerEventId: "evt_3003_retry",
|
|
225
|
+
providerCustomerId: "cus_3003",
|
|
226
|
+
providerSubscriptionId: "sub_3003",
|
|
227
|
+
}),
|
|
228
|
+
admin,
|
|
229
|
+
)) as Record<string, unknown>;
|
|
230
|
+
expect(first["duplicate"]).toBe(false);
|
|
231
|
+
|
|
232
|
+
const second = (await stack.http.writeOk(
|
|
233
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
234
|
+
buildEvent({
|
|
235
|
+
providerEventId: "evt_3003_retry",
|
|
236
|
+
providerCustomerId: "cus_3003",
|
|
237
|
+
providerSubscriptionId: "sub_3003",
|
|
238
|
+
tier: "business", // anderer tier — sollte IGNORIERT werden weil duplicate
|
|
239
|
+
}),
|
|
240
|
+
admin,
|
|
241
|
+
)) as Record<string, unknown>;
|
|
242
|
+
expect(second["duplicate"]).toBe(true);
|
|
243
|
+
|
|
244
|
+
// Drift-Pin: subscription bleibt beim ersten tier — der duplicate-
|
|
245
|
+
// event hat den state NICHT überschrieben (wäre data-loss bei
|
|
246
|
+
// out-of-order webhook-retries).
|
|
247
|
+
const subs = (await stack.http.queryOk(
|
|
248
|
+
"billing-foundation:query:subscription:list",
|
|
249
|
+
{},
|
|
250
|
+
admin,
|
|
251
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
252
|
+
expect(subs.rows[0]?.["tier"]).toBe("pro");
|
|
253
|
+
|
|
254
|
+
const esEvents = await loadAggregate(
|
|
255
|
+
db,
|
|
256
|
+
subscriptionAggregateId(admin.tenantId),
|
|
257
|
+
admin.tenantId,
|
|
258
|
+
);
|
|
259
|
+
expect(esEvents).toHaveLength(1); // dedup'd
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("scenario 4: tenant-isolation", () => {
|
|
264
|
+
test("Tenant A's subscription leakt nicht in die Liste von Tenant B", async () => {
|
|
265
|
+
const adminA = adminFor(3004);
|
|
266
|
+
const adminB = adminFor(3005);
|
|
267
|
+
|
|
268
|
+
await stack.http.writeOk(
|
|
269
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
270
|
+
buildEvent({
|
|
271
|
+
providerEventId: "evt_A",
|
|
272
|
+
providerCustomerId: "cus_A",
|
|
273
|
+
providerSubscriptionId: "sub_A",
|
|
274
|
+
tier: "pro",
|
|
275
|
+
}),
|
|
276
|
+
adminA,
|
|
277
|
+
);
|
|
278
|
+
await stack.http.writeOk(
|
|
279
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
280
|
+
buildEvent({
|
|
281
|
+
providerEventId: "evt_B",
|
|
282
|
+
providerCustomerId: "cus_B",
|
|
283
|
+
providerSubscriptionId: "sub_B",
|
|
284
|
+
tier: "business",
|
|
285
|
+
}),
|
|
286
|
+
adminB,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const subsA = (await stack.http.queryOk(
|
|
290
|
+
"billing-foundation:query:subscription:list",
|
|
291
|
+
{},
|
|
292
|
+
adminA,
|
|
293
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
294
|
+
const subsB = (await stack.http.queryOk(
|
|
295
|
+
"billing-foundation:query:subscription:list",
|
|
296
|
+
{},
|
|
297
|
+
adminB,
|
|
298
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
299
|
+
|
|
300
|
+
expect(subsA.rows).toHaveLength(1);
|
|
301
|
+
expect(subsA.rows[0]?.["tier"]).toBe("pro");
|
|
302
|
+
expect(subsB.rows).toHaveLength(1);
|
|
303
|
+
expect(subsB.rows[0]?.["tier"]).toBe("business");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("Idempotency-Anker ist tenant-scoped — selber providerEventId für ZWEI Tenants ist NICHT duplicate", async () => {
|
|
307
|
+
// Edge-case: Stripe verteilt eventIds global eindeutig. Aber
|
|
308
|
+
// theoretisch könnte ein App-Owner mehrere Stripe-Accounts haben
|
|
309
|
+
// (z.B. test/prod-Mix in dev) und gleiche eventIds sehen. Unser
|
|
310
|
+
// aggregate-id ist (tenantId, providerName, providerEventId) — also
|
|
311
|
+
// ist eventId+tenant-A unabhängig von eventId+tenant-B.
|
|
312
|
+
const adminA = adminFor(3006);
|
|
313
|
+
const adminB = adminFor(3007);
|
|
314
|
+
const SHARED_EVT = "evt_shared_id";
|
|
315
|
+
|
|
316
|
+
const a = (await stack.http.writeOk(
|
|
317
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
318
|
+
buildEvent({
|
|
319
|
+
providerEventId: SHARED_EVT,
|
|
320
|
+
providerCustomerId: "cus_a_shared",
|
|
321
|
+
providerSubscriptionId: "sub_a_shared",
|
|
322
|
+
}),
|
|
323
|
+
adminA,
|
|
324
|
+
)) as Record<string, unknown>;
|
|
325
|
+
const b = (await stack.http.writeOk(
|
|
326
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
327
|
+
buildEvent({
|
|
328
|
+
providerEventId: SHARED_EVT,
|
|
329
|
+
providerCustomerId: "cus_b_shared",
|
|
330
|
+
providerSubscriptionId: "sub_b_shared",
|
|
331
|
+
}),
|
|
332
|
+
adminB,
|
|
333
|
+
)) as Record<string, unknown>;
|
|
334
|
+
|
|
335
|
+
expect(a["duplicate"]).toBe(false);
|
|
336
|
+
expect(b["duplicate"]).toBe(false); // anderer tenant → kein duplicate
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("scenario 5: Provider-Wechsel mid-period (Disney+-Pattern)", () => {
|
|
341
|
+
test("Tenant switcht von Stripe zu PayPal: subscription-row updated providerName, subscription-event-history zeigt beide", async () => {
|
|
342
|
+
// **Disney+-Use-Case:** Tenant hat Stripe-sub, will umsteigen auf
|
|
343
|
+
// PayPal. Cancel des Stripe-sub + neue subscription via PayPal sind
|
|
344
|
+
// zwei getrennte Webhook-events vom Endkunden-Action ausgelöst.
|
|
345
|
+
// Foundation muss damit umgehen können — eine subscription-row
|
|
346
|
+
// pro Tenant, providerName tracked welcher Provider gerade hält.
|
|
347
|
+
const admin = adminFor(3009);
|
|
348
|
+
|
|
349
|
+
// Stripe-Sub erzeugt
|
|
350
|
+
await stack.http.writeOk(
|
|
351
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
352
|
+
buildEvent({
|
|
353
|
+
providerEventId: "evt_stripe_create",
|
|
354
|
+
providerCustomerId: "cus_stripe",
|
|
355
|
+
providerSubscriptionId: "sub_stripe",
|
|
356
|
+
tier: "pro",
|
|
357
|
+
}),
|
|
358
|
+
admin,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
let subs = (await stack.http.queryOk(
|
|
362
|
+
"billing-foundation:query:subscription:list",
|
|
363
|
+
{},
|
|
364
|
+
admin,
|
|
365
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
366
|
+
expect(subs.rows).toHaveLength(1);
|
|
367
|
+
expect(subs.rows[0]?.["providerName"]).toBe("stripe");
|
|
368
|
+
|
|
369
|
+
// Stripe-Cancel + PayPal-Create kommen — PayPal-Plugin liefert
|
|
370
|
+
// SubscriptionEvent mit providerName="paypal".
|
|
371
|
+
const paypalEvent = {
|
|
372
|
+
...buildEvent({
|
|
373
|
+
providerEventId: "I-PAYPAL-NEW",
|
|
374
|
+
providerCustomerId: "PP-CUST-001",
|
|
375
|
+
providerSubscriptionId: "I-PAYPAL-NEW",
|
|
376
|
+
tier: "pro",
|
|
377
|
+
}),
|
|
378
|
+
providerName: "paypal",
|
|
379
|
+
};
|
|
380
|
+
await stack.http.writeOk(SubscriptionFoundationHandlers.processEvent, paypalEvent, admin);
|
|
381
|
+
|
|
382
|
+
// subscription-row geupdated, providerName ist jetzt "paypal".
|
|
383
|
+
// **Drift-Pin:** Eine subscription-row pro Tenant — der Wechsel
|
|
384
|
+
// überschreibt die alte Provider-Daten, history liegt in
|
|
385
|
+
// subscription-event-rows.
|
|
386
|
+
subs = (await stack.http.queryOk("billing-foundation:query:subscription:list", {}, admin)) as {
|
|
387
|
+
rows: Array<Record<string, unknown>>;
|
|
388
|
+
};
|
|
389
|
+
expect(subs.rows).toHaveLength(1);
|
|
390
|
+
expect(subs.rows[0]?.["providerName"]).toBe("paypal");
|
|
391
|
+
expect(subs.rows[0]?.["providerCustomerId"]).toBe("PP-CUST-001");
|
|
392
|
+
expect(subs.rows[0]?.["providerSubscriptionId"]).toBe("I-PAYPAL-NEW");
|
|
393
|
+
|
|
394
|
+
// History: beide events im subscription-stream archiviert
|
|
395
|
+
const esEvents = await loadAggregate(
|
|
396
|
+
db,
|
|
397
|
+
subscriptionAggregateId(admin.tenantId),
|
|
398
|
+
admin.tenantId,
|
|
399
|
+
);
|
|
400
|
+
expect(esEvents).toHaveLength(2);
|
|
401
|
+
const providerNames = esEvents
|
|
402
|
+
.map((e) => e.metadata.headers?.["providerName"] as string | undefined)
|
|
403
|
+
.filter((p): p is string => p !== undefined)
|
|
404
|
+
.sort();
|
|
405
|
+
expect(providerNames).toEqual(["paypal", "stripe"]);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// =============================================================================
|
|
410
|
+
// Scenarios 6+7 — Phase-5.2b write-handlers (create-checkout-session +
|
|
411
|
+
// create-portal-session). Foundation-routing-tests; provider-spezifisches
|
|
412
|
+
// Verhalten (echte Stripe-checkout-URL) wird in subscription-stripe getestet.
|
|
413
|
+
// =============================================================================
|
|
414
|
+
|
|
415
|
+
describe("scenario 6: create-checkout-session — Plugin-routing", () => {
|
|
416
|
+
test("happy-path: valid provider → URL durchgereicht + plugin mit korrekten args aufgerufen", async () => {
|
|
417
|
+
mockCheckoutCalls.length = 0;
|
|
418
|
+
const admin = adminFor(3009);
|
|
419
|
+
const result = (await stack.http.writeOk(
|
|
420
|
+
"billing-foundation:write:create-checkout-session",
|
|
421
|
+
{
|
|
422
|
+
providerName: "mock",
|
|
423
|
+
priceId: "price_pro_test",
|
|
424
|
+
successUrl: "https://example.com/success",
|
|
425
|
+
cancelUrl: "https://example.com/cancel",
|
|
426
|
+
},
|
|
427
|
+
admin,
|
|
428
|
+
)) as Record<string, unknown>;
|
|
429
|
+
|
|
430
|
+
expect(result["url"]).toBe("https://mock.example/checkout/price_pro_test");
|
|
431
|
+
expect(result["providerName"]).toBe("mock");
|
|
432
|
+
|
|
433
|
+
// Drift-pin: foundation-handler reicht alle payload-Felder + die
|
|
434
|
+
// resolved tenantId an den Plugin durch. Wenn jemand silent
|
|
435
|
+
// umbenennt (z.B. successUrl → success_url im handler), würde
|
|
436
|
+
// mockCheckoutCalls die alten Felder vermissen.
|
|
437
|
+
expect(mockCheckoutCalls).toHaveLength(1);
|
|
438
|
+
expect(mockCheckoutCalls[0]).toEqual({
|
|
439
|
+
priceId: "price_pro_test",
|
|
440
|
+
tenantId: admin.tenantId,
|
|
441
|
+
successUrl: "https://example.com/success",
|
|
442
|
+
cancelUrl: "https://example.com/cancel",
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("provider not registered → klarer error mit known-list", async () => {
|
|
447
|
+
const admin = adminFor(3010);
|
|
448
|
+
const error = await stack.http.writeErr(
|
|
449
|
+
"billing-foundation:write:create-checkout-session",
|
|
450
|
+
{
|
|
451
|
+
providerName: "non-existent-provider",
|
|
452
|
+
priceId: "price_test",
|
|
453
|
+
successUrl: "https://example.com/success",
|
|
454
|
+
cancelUrl: "https://example.com/cancel",
|
|
455
|
+
},
|
|
456
|
+
admin,
|
|
457
|
+
);
|
|
458
|
+
expect(JSON.stringify(error)).toMatch(/not registered/);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("optional providerCustomerId wird durchgereicht (Plan-Wechsel-Flow)", async () => {
|
|
462
|
+
mockCheckoutCalls.length = 0;
|
|
463
|
+
const admin = adminFor(3012);
|
|
464
|
+
await stack.http.writeOk(
|
|
465
|
+
"billing-foundation:write:create-checkout-session",
|
|
466
|
+
{
|
|
467
|
+
providerName: "mock",
|
|
468
|
+
priceId: "price_business_test",
|
|
469
|
+
successUrl: "https://example.com/s",
|
|
470
|
+
cancelUrl: "https://example.com/c",
|
|
471
|
+
providerCustomerId: "cus_existing_xyz",
|
|
472
|
+
},
|
|
473
|
+
admin,
|
|
474
|
+
);
|
|
475
|
+
expect(mockCheckoutCalls[0]?.providerCustomerId).toBe("cus_existing_xyz");
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("scenario 7: create-portal-session — Plugin-routing", () => {
|
|
480
|
+
test("happy-path: tenant hat subscription → portal-URL durchgereicht", async () => {
|
|
481
|
+
mockPortalCalls.length = 0;
|
|
482
|
+
const admin = adminFor(3013);
|
|
483
|
+
|
|
484
|
+
// Erst: subscription via process-event erzeugen — providerName "mock"
|
|
485
|
+
// damit Foundation-handler den Plugin via lookup findet.
|
|
486
|
+
await stack.http.writeOk(
|
|
487
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
488
|
+
{
|
|
489
|
+
...buildEvent({
|
|
490
|
+
providerEventId: "evt_3013_create",
|
|
491
|
+
providerCustomerId: "cus_3013",
|
|
492
|
+
providerSubscriptionId: "sub_3013",
|
|
493
|
+
}),
|
|
494
|
+
providerName: "mock",
|
|
495
|
+
},
|
|
496
|
+
admin,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const result = (await stack.http.writeOk(
|
|
500
|
+
"billing-foundation:write:create-portal-session",
|
|
501
|
+
{ returnUrl: "https://example.com/return" },
|
|
502
|
+
admin,
|
|
503
|
+
)) as Record<string, unknown>;
|
|
504
|
+
|
|
505
|
+
expect(result["url"]).toBe("https://mock.example/portal/cus_3013");
|
|
506
|
+
expect(result["providerName"]).toBe("mock");
|
|
507
|
+
|
|
508
|
+
// Drift-pin: portal-handler liest providerCustomerId AUS DER DB
|
|
509
|
+
// (subscription-row), nicht aus der payload. Wenn ein Refactor das
|
|
510
|
+
// umstellt (= Tenant könnte fremde portal-sessions öffnen), würde
|
|
511
|
+
// mockPortalCalls den falschen customer-id sehen.
|
|
512
|
+
expect(mockPortalCalls).toHaveLength(1);
|
|
513
|
+
expect(mockPortalCalls[0]).toEqual({
|
|
514
|
+
providerCustomerId: "cus_3013",
|
|
515
|
+
returnUrl: "https://example.com/return",
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("Tenant ohne subscription → 'no active subscription'-error", async () => {
|
|
520
|
+
const admin = adminFor(3011);
|
|
521
|
+
const error = await stack.http.writeErr(
|
|
522
|
+
"billing-foundation:write:create-portal-session",
|
|
523
|
+
{ returnUrl: "https://example.com/return" },
|
|
524
|
+
admin,
|
|
525
|
+
);
|
|
526
|
+
expect(JSON.stringify(error)).toMatch(/no active subscription/);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("scenario 8: cancel-event setzt status auf canceled, behält subscription-row", () => {
|
|
531
|
+
test("subscription.canceled event flippt status, subscription-row bleibt", async () => {
|
|
532
|
+
const admin = adminFor(3008);
|
|
533
|
+
|
|
534
|
+
await stack.http.writeOk(
|
|
535
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
536
|
+
buildEvent({
|
|
537
|
+
providerEventId: "evt_3008_create",
|
|
538
|
+
providerCustomerId: "cus_3008",
|
|
539
|
+
providerSubscriptionId: "sub_3008",
|
|
540
|
+
status: SubscriptionStatuses.active,
|
|
541
|
+
tier: "pro",
|
|
542
|
+
}),
|
|
543
|
+
admin,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
await stack.http.writeOk(
|
|
547
|
+
SubscriptionFoundationHandlers.processEvent,
|
|
548
|
+
buildEvent({
|
|
549
|
+
providerEventId: "evt_3008_cancel",
|
|
550
|
+
type: SubscriptionEventTypes.canceled,
|
|
551
|
+
providerCustomerId: "cus_3008",
|
|
552
|
+
providerSubscriptionId: "sub_3008",
|
|
553
|
+
status: SubscriptionStatuses.canceled,
|
|
554
|
+
tier: "free", // downgrade auf free
|
|
555
|
+
}),
|
|
556
|
+
admin,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const subs = (await stack.http.queryOk(
|
|
560
|
+
"billing-foundation:query:subscription:list",
|
|
561
|
+
{},
|
|
562
|
+
admin,
|
|
563
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
564
|
+
expect(subs.rows).toHaveLength(1); // row bleibt für audit-history
|
|
565
|
+
expect(subs.rows[0]?.["status"]).toBe(SubscriptionStatuses.canceled);
|
|
566
|
+
expect(subs.rows[0]?.["tier"]).toBe("free");
|
|
567
|
+
});
|
|
568
|
+
});
|