@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,388 @@
|
|
|
1
|
+
// Unit-Tests für verifyAndParseMollieWebhook — Mollie's classic
|
|
2
|
+
// webhook-pattern (lazy-fetch, ohne sig-verify). Mollie-Client wird
|
|
3
|
+
// als minimal-mock-shape (`MollieClientShape`) injiziert; Plugin-
|
|
4
|
+
// Verhalten ist vom konkreten Mollie-SDK entkoppelt.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
SubscriptionEventTypes,
|
|
8
|
+
SubscriptionStatuses,
|
|
9
|
+
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
10
|
+
import { describe, expect, test, vi } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
extractMollieId,
|
|
13
|
+
type MollieClientShape,
|
|
14
|
+
mapMollieEventType,
|
|
15
|
+
mapMollieStatus,
|
|
16
|
+
verifyAndParseMollieWebhook,
|
|
17
|
+
} from "../verify-webhook";
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Test-helpers
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
function buildMockSubscription(overrides: Partial<Record<string, unknown>> = {}) {
|
|
24
|
+
return {
|
|
25
|
+
id: "sub_test_001",
|
|
26
|
+
customerId: "cst_test_001",
|
|
27
|
+
status: "active",
|
|
28
|
+
nextPaymentDate: "2026-06-15",
|
|
29
|
+
startDate: "2026-05-15",
|
|
30
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_pro" },
|
|
31
|
+
...overrides,
|
|
32
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal mock — wir nutzen nur 4 Felder
|
|
33
|
+
} as any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildMockPayment(overrides: Partial<Record<string, unknown>> = {}) {
|
|
37
|
+
return {
|
|
38
|
+
id: "tr_test_001",
|
|
39
|
+
customerId: "cst_test_001",
|
|
40
|
+
subscriptionId: "sub_test_001",
|
|
41
|
+
sequenceType: "first",
|
|
42
|
+
status: "paid",
|
|
43
|
+
...overrides,
|
|
44
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal mock
|
|
45
|
+
} as any;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildClient(
|
|
49
|
+
overrides: {
|
|
50
|
+
paymentResolve?: ReturnType<typeof buildMockPayment>;
|
|
51
|
+
paymentReject?: Error;
|
|
52
|
+
subResolve?: ReturnType<typeof buildMockSubscription>;
|
|
53
|
+
subReject?: Error;
|
|
54
|
+
/** Bestehende subs die `customerSubscriptions.list` zurückgibt (für
|
|
55
|
+
* mandate-setup-idempotency-tests). */
|
|
56
|
+
listResolve?: ReturnType<typeof buildMockSubscription>[];
|
|
57
|
+
/** Was `customerSubscriptions.create` zurückgibt. Default: eine
|
|
58
|
+
* neu-erstellte sub mit metadata vom payment. */
|
|
59
|
+
createResolve?: ReturnType<typeof buildMockSubscription>;
|
|
60
|
+
} = {},
|
|
61
|
+
): MollieClientShape {
|
|
62
|
+
return {
|
|
63
|
+
payments: {
|
|
64
|
+
get: vi.fn(async () => {
|
|
65
|
+
if (overrides.paymentReject) throw overrides.paymentReject;
|
|
66
|
+
return overrides.paymentResolve ?? buildMockPayment();
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
customerSubscriptions: {
|
|
70
|
+
get: vi.fn(async () => {
|
|
71
|
+
if (overrides.subReject) throw overrides.subReject;
|
|
72
|
+
return overrides.subResolve ?? buildMockSubscription();
|
|
73
|
+
}),
|
|
74
|
+
list: vi.fn(async () => overrides.listResolve ?? []),
|
|
75
|
+
create: vi.fn(
|
|
76
|
+
async () => overrides.createResolve ?? buildMockSubscription({ id: "sub_just_created" }),
|
|
77
|
+
),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const TEST_PRICE_CONFIG = {
|
|
83
|
+
plan_pro: {
|
|
84
|
+
amountValue: "9.99",
|
|
85
|
+
amountCurrency: "EUR",
|
|
86
|
+
interval: "1 month",
|
|
87
|
+
description: "Pro-Abo monatlich",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// =============================================================================
|
|
92
|
+
// extractMollieId — body-parsing
|
|
93
|
+
// =============================================================================
|
|
94
|
+
|
|
95
|
+
describe("extractMollieId", () => {
|
|
96
|
+
test("form-urlencoded body → extract id", () => {
|
|
97
|
+
expect(
|
|
98
|
+
extractMollieId("id=tr_xxx", { "content-type": "application/x-www-form-urlencoded" }),
|
|
99
|
+
).toBe("tr_xxx");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("default content-type wird als form-urlencoded behandelt (Mollie-classic)", () => {
|
|
103
|
+
expect(extractMollieId("id=sub_yyy", {})).toBe("sub_yyy");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("JSON body mit content-type", () => {
|
|
107
|
+
expect(extractMollieId('{"id":"tr_zzz"}', { "content-type": "application/json" })).toBe(
|
|
108
|
+
"tr_zzz",
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("malformed JSON → null statt throw (defensive)", () => {
|
|
113
|
+
expect(extractMollieId("not json", { "content-type": "application/json" })).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("body ohne id → null", () => {
|
|
117
|
+
expect(extractMollieId("foo=bar", {})).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// =============================================================================
|
|
122
|
+
// Lazy-fetch
|
|
123
|
+
// =============================================================================
|
|
124
|
+
|
|
125
|
+
describe("verifyAndParseMollieWebhook — payment-event happy path", () => {
|
|
126
|
+
const verify = (client: MollieClientShape) =>
|
|
127
|
+
verifyAndParseMollieWebhook(client, {
|
|
128
|
+
priceToTier: { plan_pro: "pro" },
|
|
129
|
+
priceToConfig: TEST_PRICE_CONFIG,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("tr_xxx → fetch payment + subscription → SubscriptionEvent.created (first-payment paid)", async () => {
|
|
133
|
+
const client = buildClient();
|
|
134
|
+
const event = await verify(client)("id=tr_test_001", {});
|
|
135
|
+
expect(event).not.toBeNull();
|
|
136
|
+
expect(event?.providerName).toBe("mollie");
|
|
137
|
+
expect(event?.providerEventId).toBe("tr_test_001");
|
|
138
|
+
expect(event?.type).toBe(SubscriptionEventTypes.created);
|
|
139
|
+
expect(event?.tenantId).toBe("tenant-test");
|
|
140
|
+
expect(event?.tier).toBe("pro");
|
|
141
|
+
expect(event?.providerSubscriptionId).toBe("sub_test_001");
|
|
142
|
+
expect(event?.providerCustomerId).toBe("cst_test_001");
|
|
143
|
+
expect(event?.status).toBe(SubscriptionStatuses.active);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("recurring-payment paid → invoicePaid", async () => {
|
|
147
|
+
const client = buildClient({
|
|
148
|
+
paymentResolve: buildMockPayment({ sequenceType: "recurring", status: "paid" }),
|
|
149
|
+
});
|
|
150
|
+
const event = await verify(client)("id=tr_renewal_001", {});
|
|
151
|
+
expect(event?.type).toBe(SubscriptionEventTypes.invoicePaid);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("recurring-payment failed → invoicePaymentFailed", async () => {
|
|
155
|
+
const client = buildClient({
|
|
156
|
+
paymentResolve: buildMockPayment({ sequenceType: "recurring", status: "failed" }),
|
|
157
|
+
});
|
|
158
|
+
const event = await verify(client)("id=tr_failed_001", {});
|
|
159
|
+
expect(event?.type).toBe(SubscriptionEventTypes.invoicePaymentFailed);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("subscription canceled → SubscriptionEventTypes.canceled (egal welche payment-status)", async () => {
|
|
163
|
+
const client = buildClient({
|
|
164
|
+
subResolve: buildMockSubscription({ status: "canceled" }),
|
|
165
|
+
});
|
|
166
|
+
const event = await verify(client)("id=tr_test_001", {});
|
|
167
|
+
expect(event?.type).toBe(SubscriptionEventTypes.canceled);
|
|
168
|
+
expect(event?.status).toBe(SubscriptionStatuses.canceled);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("verifyAndParseMollieWebhook — mandate-setup-flow (= first-payment-paid OHNE existierende sub)", () => {
|
|
173
|
+
const verify = (client: MollieClientShape) =>
|
|
174
|
+
verifyAndParseMollieWebhook(client, {
|
|
175
|
+
priceToTier: { plan_pro: "pro" },
|
|
176
|
+
priceToConfig: TEST_PRICE_CONFIG,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("first-payment paid, subscriptionId=null → list ist leer → ensureSubscription erstellt neue Sub → Created-Event mit der neuen sub-id", async () => {
|
|
180
|
+
const newlyCreatedSub = buildMockSubscription({
|
|
181
|
+
id: "sub_just_created",
|
|
182
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_pro" },
|
|
183
|
+
});
|
|
184
|
+
const client = buildClient({
|
|
185
|
+
paymentResolve: buildMockPayment({
|
|
186
|
+
subscriptionId: null,
|
|
187
|
+
sequenceType: "first",
|
|
188
|
+
status: "paid",
|
|
189
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_pro" },
|
|
190
|
+
}),
|
|
191
|
+
listResolve: [],
|
|
192
|
+
createResolve: newlyCreatedSub,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const event = await verify(client)("id=tr_first_paid_001", {});
|
|
196
|
+
|
|
197
|
+
expect(event).not.toBeNull();
|
|
198
|
+
expect(event?.type).toBe(SubscriptionEventTypes.created);
|
|
199
|
+
expect(event?.providerSubscriptionId).toBe("sub_just_created");
|
|
200
|
+
expect(client.customerSubscriptions.create).toHaveBeenCalledExactlyOnceWith("cst_test_001", {
|
|
201
|
+
amount: { currency: "EUR", value: "9.99" },
|
|
202
|
+
interval: "1 month",
|
|
203
|
+
description: "Pro-Abo monatlich",
|
|
204
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_pro" },
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("Replay (Mollie sendet webhook nochmal) → list findet existing-active-sub für priceId → kein zweiter create", async () => {
|
|
209
|
+
const existingSub = buildMockSubscription({
|
|
210
|
+
id: "sub_already_there",
|
|
211
|
+
status: "active",
|
|
212
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_pro" },
|
|
213
|
+
});
|
|
214
|
+
const client = buildClient({
|
|
215
|
+
paymentResolve: buildMockPayment({
|
|
216
|
+
subscriptionId: null,
|
|
217
|
+
sequenceType: "first",
|
|
218
|
+
status: "paid",
|
|
219
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_pro" },
|
|
220
|
+
}),
|
|
221
|
+
listResolve: [existingSub],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const event = await verify(client)("id=tr_first_paid_replay", {});
|
|
225
|
+
|
|
226
|
+
expect(event?.providerSubscriptionId).toBe("sub_already_there");
|
|
227
|
+
expect(client.customerSubscriptions.create).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("List hat sub für ANDEREN priceId (= App-Builder bietet Plan-Wechsel) → trotzdem create für neuen priceId", async () => {
|
|
231
|
+
const otherPlanSub = buildMockSubscription({
|
|
232
|
+
id: "sub_basic_old",
|
|
233
|
+
status: "active",
|
|
234
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_basic" },
|
|
235
|
+
});
|
|
236
|
+
const client = buildClient({
|
|
237
|
+
paymentResolve: buildMockPayment({
|
|
238
|
+
subscriptionId: null,
|
|
239
|
+
sequenceType: "first",
|
|
240
|
+
status: "paid",
|
|
241
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_pro" },
|
|
242
|
+
}),
|
|
243
|
+
listResolve: [otherPlanSub],
|
|
244
|
+
createResolve: buildMockSubscription({
|
|
245
|
+
id: "sub_pro_new",
|
|
246
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_pro" },
|
|
247
|
+
}),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const event = await verify(client)("id=tr_upgrade", {});
|
|
251
|
+
|
|
252
|
+
expect(event?.providerSubscriptionId).toBe("sub_pro_new");
|
|
253
|
+
expect(client.customerSubscriptions.create).toHaveBeenCalledOnce();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("verifyAndParseMollieWebhook — error + ignore paths", () => {
|
|
258
|
+
const verify = (client: MollieClientShape) =>
|
|
259
|
+
verifyAndParseMollieWebhook(client, {
|
|
260
|
+
priceToTier: { plan_pro: "pro" },
|
|
261
|
+
priceToConfig: TEST_PRICE_CONFIG,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("body ohne id → throws", async () => {
|
|
265
|
+
const client = buildClient();
|
|
266
|
+
await expect(verify(client)("not-an-id-form", {})).rejects.toThrow(/no `id` field/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("unbekannte ID-form (kein tr_/sub_ prefix) → null", async () => {
|
|
270
|
+
const client = buildClient();
|
|
271
|
+
expect(await verify(client)("id=unknown_xyz", {})).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("Mollie-API rejectet payment-fetch (= garbage-id) → null (foundation 200 ignored)", async () => {
|
|
275
|
+
const client = buildClient({ paymentReject: new Error("Mollie 404: not found") });
|
|
276
|
+
expect(await verify(client)("id=tr_garbage", {})).toBeNull();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("payment ohne subscriptionId UND nicht first-payment-paid → null (= one-shot, nicht unsere domain)", async () => {
|
|
280
|
+
const client = buildClient({
|
|
281
|
+
paymentResolve: buildMockPayment({ subscriptionId: null, sequenceType: "oneoff" }),
|
|
282
|
+
});
|
|
283
|
+
expect(await verify(client)("id=tr_oneshot", {})).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("first-payment-paid OHNE payment.metadata → null (App-Builder hat tenantId/priceId nicht gesetzt)", async () => {
|
|
287
|
+
const client = buildClient({
|
|
288
|
+
paymentResolve: buildMockPayment({
|
|
289
|
+
subscriptionId: null,
|
|
290
|
+
sequenceType: "first",
|
|
291
|
+
status: "paid",
|
|
292
|
+
metadata: null,
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
expect(await verify(client)("id=tr_no_metadata", {})).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("first-payment-paid mit unbekanntem priceId → null (priceToConfig-Drift)", async () => {
|
|
299
|
+
const client = buildClient({
|
|
300
|
+
paymentResolve: buildMockPayment({
|
|
301
|
+
subscriptionId: null,
|
|
302
|
+
sequenceType: "first",
|
|
303
|
+
status: "paid",
|
|
304
|
+
metadata: { tenantId: "tenant-test", priceId: "plan_unknown" },
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
expect(await verify(client)("id=tr_unknown_price", {})).toBeNull();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("subscription ohne metadata.tenantId → null", async () => {
|
|
311
|
+
const client = buildClient({
|
|
312
|
+
subResolve: buildMockSubscription({ metadata: { priceId: "plan_pro" } }),
|
|
313
|
+
});
|
|
314
|
+
expect(await verify(client)("id=tr_no_tenant", {})).toBeNull();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("priceId nicht im Mapping → null", async () => {
|
|
318
|
+
const client = buildClient({
|
|
319
|
+
subResolve: buildMockSubscription({
|
|
320
|
+
metadata: { tenantId: "t", priceId: "plan_unknown" },
|
|
321
|
+
}),
|
|
322
|
+
});
|
|
323
|
+
expect(await verify(client)("id=tr_test_001", {})).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("sub_xxx-direct-events sind heute NICHT supported (= null) — App-Builder bekommt sie indirekt via tr_xxx-payment-events", async () => {
|
|
327
|
+
const client = buildClient();
|
|
328
|
+
expect(await verify(client)("id=sub_direct_evt", {})).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("subscription ohne nextPaymentDate UND ohne startDate → throws (Mollie-API-Drift)", async () => {
|
|
332
|
+
const client = buildClient({
|
|
333
|
+
subResolve: buildMockSubscription({ nextPaymentDate: null, startDate: null }),
|
|
334
|
+
});
|
|
335
|
+
await expect(verify(client)("id=tr_no_dates", {})).rejects.toThrow(
|
|
336
|
+
/has neither nextPaymentDate nor startDate/,
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("malformed nextPaymentDate (z.B. 'invalid') → throws (Mollie-API-Drift)", async () => {
|
|
341
|
+
const client = buildClient({
|
|
342
|
+
subResolve: buildMockSubscription({ nextPaymentDate: "garbage-date" }),
|
|
343
|
+
});
|
|
344
|
+
// Temporal.Instant.from wirft RangeError bei malformed input.
|
|
345
|
+
await expect(verify(client)("id=tr_bad_date", {})).rejects.toThrow();
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// =============================================================================
|
|
350
|
+
// Mapping-helpers
|
|
351
|
+
// =============================================================================
|
|
352
|
+
|
|
353
|
+
describe("mapMollieStatus — Mollie-status → normalized", () => {
|
|
354
|
+
test("active/canceled/completed", () => {
|
|
355
|
+
// Mollie-status-strings sind ein typed enum, casts hier weil wir
|
|
356
|
+
// die Werte als plain literals testen (analog zu wie sie aus dem
|
|
357
|
+
// Mollie-API als `status: string` JSON kommen).
|
|
358
|
+
expect(mapMollieStatus("active" as never)).toBe(SubscriptionStatuses.active);
|
|
359
|
+
expect(mapMollieStatus("canceled" as never)).toBe(SubscriptionStatuses.canceled);
|
|
360
|
+
// completed = sub ist fertig (alle times-charges durch) — wie canceled
|
|
361
|
+
expect(mapMollieStatus("completed" as never)).toBe(SubscriptionStatuses.canceled);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("suspended → past_due (= grace-period bei mandate-fail)", () => {
|
|
365
|
+
expect(mapMollieStatus("suspended" as never)).toBe(SubscriptionStatuses.pastDue);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("pending → incomplete (= mandate noch nicht confirmed)", () => {
|
|
369
|
+
expect(mapMollieStatus("pending" as never)).toBe(SubscriptionStatuses.incomplete);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("mapMollieEventType — heuristik", () => {
|
|
374
|
+
test("subscription canceled trumps alles", () => {
|
|
375
|
+
const sub = buildMockSubscription({ status: "canceled" });
|
|
376
|
+
expect(mapMollieEventType(sub, null)).toBe(SubscriptionEventTypes.canceled);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("active sub ohne payment-context → updated (z.B. metadata-change via API)", () => {
|
|
380
|
+
const sub = buildMockSubscription({ status: "active" });
|
|
381
|
+
expect(mapMollieEventType(sub, null)).toBe(SubscriptionEventTypes.updated);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("pending sub ohne payment-context → null (= noch nichts entschieden)", () => {
|
|
385
|
+
const sub = buildMockSubscription({ status: "pending" });
|
|
386
|
+
expect(mapMollieEventType(sub, null)).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Feature name
|
|
2
|
+
export const SUBSCRIPTION_MOLLIE_FEATURE = "subscription-mollie" as const;
|
|
3
|
+
|
|
4
|
+
// entityName under den der Plugin gegen "subscriptionProvider"
|
|
5
|
+
// registriert. Matcht den path-segment in der webhook-URL
|
|
6
|
+
// `/api/subscription/webhook/mollie`.
|
|
7
|
+
export const MOLLIE_PROVIDER_NAME = "mollie" as const;
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// Mollie-Webhook-Pattern (anders als Stripe!)
|
|
11
|
+
// =============================================================================
|
|
12
|
+
//
|
|
13
|
+
// Mollie sendet bei JEDEM event nur die ID im body (form-urlencoded
|
|
14
|
+
// oder JSON). Plugin muss IMMER lazy-fetchen via Mollie-API:
|
|
15
|
+
// - `id=tr_xxx` → payment-id, fetch payment + subscription
|
|
16
|
+
// - `id=sub_xxx` → subscription-id, fetch subscription direkt
|
|
17
|
+
//
|
|
18
|
+
// **Keine native HMAC-sig-verify** in Mollie-SDK 4.5.0. Mollie's
|
|
19
|
+
// Sicherheits-Modell stützt sich auf nicht-guessable IDs (~10^25
|
|
20
|
+
// brute-force-space) + API-Lookup-Validation (garbage-id → 401 von
|
|
21
|
+
// Mollie-API). Plus: App-Builder kann zusätzlich einen URL-Token-
|
|
22
|
+
// Wrapper vor die Foundation-route schalten (eigener extraRoute der
|
|
23
|
+
// einen secret-token im URL-Pfad verifiziert + dann an die Foundation
|
|
24
|
+
// weiterreicht).
|
|
25
|
+
//
|
|
26
|
+
// **Event-Type-Mapping** ist heuristisch — Mollie hat keine explicit-
|
|
27
|
+
// typed events:
|
|
28
|
+
// - sub_xxx + status=canceled/completed → SubscriptionEventTypes.canceled
|
|
29
|
+
// - sub_xxx + status=active/pending → SubscriptionEventTypes.updated
|
|
30
|
+
// - tr_xxx + sequenceType=first + paid → SubscriptionEventTypes.created
|
|
31
|
+
// - tr_xxx + sequenceType=recurring + paid → SubscriptionEventTypes.invoicePaid
|
|
32
|
+
// - tr_xxx + sequenceType=recurring + failed → SubscriptionEventTypes.invoicePaymentFailed
|
|
33
|
+
// - alles andere → null (foundation 200 ignored)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// subscription-mollie — Mollie-Plugin für die subscription-foundation
|
|
4
|
+
// Plugin-API (EU-Compliance-Story für DACH-Mid-Market).
|
|
5
|
+
//
|
|
6
|
+
// **Factory-Pattern (analog subscription-stripe):** liest API-key beim
|
|
7
|
+
// mount-time aus den factory-options (typisch process.env aus dem App-
|
|
8
|
+
// Builder-bin/server.ts). Mollie hat KEIN webhook-secret — Mollie-SDK
|
|
9
|
+
// 4.5.0 bietet keine native HMAC-sig-verify-API. Sicherheit kommt aus
|
|
10
|
+
// nicht-guessable IDs + API-Validation; App-Builder kann optional
|
|
11
|
+
// einen URL-Token-Wrapper davor schalten.
|
|
12
|
+
//
|
|
13
|
+
// **Phase-5.3-Scope:**
|
|
14
|
+
// - verifyAndParseWebhook ✓ (lazy-fetch payment + subscription via
|
|
15
|
+
// Mollie-API, heuristisches event-type-mapping)
|
|
16
|
+
// - createCheckoutSession ✓ (Mollie's mehrstufiger flow: customer
|
|
17
|
+
// anlegen + first-payment mit sequenceType="first" → redirectUrl)
|
|
18
|
+
// - createPortalSession ✗ (Mollie hat keinen Customer-Portal)
|
|
19
|
+
// - cancelSubscription ✗ (Mollie braucht customerId zusätzlich zur
|
|
20
|
+
// subId; Plugin-Contract reicht's nicht durch — App-Builder cancelt
|
|
21
|
+
// via Mollie-Dashboard oder custom-route)
|
|
22
|
+
//
|
|
23
|
+
// **Beispiel-Verwendung in run-config.ts:**
|
|
24
|
+
//
|
|
25
|
+
// const features = [
|
|
26
|
+
// billingFoundationFeature,
|
|
27
|
+
// createSubscriptionMollieFeature({
|
|
28
|
+
// apiKey: process.env.MOLLIE_API_KEY ?? "",
|
|
29
|
+
// webhookUrl: "https://app.example.com/api/subscription/webhook/mollie",
|
|
30
|
+
// priceToTier: { plan_pro: "pro", plan_business: "business" },
|
|
31
|
+
// priceToConfig: {
|
|
32
|
+
// plan_pro: {
|
|
33
|
+
// amountValue: "9.99",
|
|
34
|
+
// amountCurrency: "EUR",
|
|
35
|
+
// interval: "1 month",
|
|
36
|
+
// description: "Pro-Abo monatlich",
|
|
37
|
+
// },
|
|
38
|
+
// },
|
|
39
|
+
// }),
|
|
40
|
+
// ];
|
|
41
|
+
|
|
42
|
+
import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
43
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
44
|
+
import { createMollieClient } from "@mollie/api-client";
|
|
45
|
+
import { MOLLIE_PROVIDER_NAME, SUBSCRIPTION_MOLLIE_FEATURE } from "./constants";
|
|
46
|
+
import { createMollieCheckoutSession, type MolliePriceConfig } from "./plugin-methods";
|
|
47
|
+
import { type MollieClientShape, verifyAndParseMollieWebhook } from "./verify-webhook";
|
|
48
|
+
|
|
49
|
+
export type SubscriptionMollieOptions = {
|
|
50
|
+
/** Mollie-API-key (`test_...` oder `live_...`). App-wide, beim Plugin-
|
|
51
|
+
* mount aus process.env oder system-config. */
|
|
52
|
+
readonly apiKey: string;
|
|
53
|
+
/** Foundation-webhook-URL die der App-Builder unter Mollie-Dashboard
|
|
54
|
+
* als webhook eingetragen hat. Plugin reicht das beim payment-
|
|
55
|
+
* create an Mollie weiter — Mollie sendet beim payment-event
|
|
56
|
+
* webhooks an diese URL. Typisch:
|
|
57
|
+
* `https://app.example.com/api/subscription/webhook/mollie`. */
|
|
58
|
+
readonly webhookUrl: string;
|
|
59
|
+
/** Price-to-tier-Map. Plugin liest die priceId aus dem subscription-
|
|
60
|
+
* metadata (= `metadata.priceId` den der App-Builder beim
|
|
61
|
+
* createCheckoutSession setzt) und mappt auf einen tier-name. */
|
|
62
|
+
readonly priceToTier: Readonly<Record<string, string>>;
|
|
63
|
+
/** Mollie-Subscription-Setup pro priceId. Mollie hat keinen nativen
|
|
64
|
+
* price-id-Konzept — App-Builder muss pro virtuellem priceId einen
|
|
65
|
+
* amount/interval/description bereitstellen. */
|
|
66
|
+
readonly priceToConfig: Readonly<Record<string, MolliePriceConfig>>;
|
|
67
|
+
/** @internal Test-only injection-port: ersetzt den verify-webhook-
|
|
68
|
+
* fetch-client durch eine Mock-instance. Production left undefined —
|
|
69
|
+
* factory baut den Adapter aus `createMollieClient(apiKey)`. Wird
|
|
70
|
+
* vom IT (mollie-foundation.integration.ts) genutzt damit der echte
|
|
71
|
+
* factory-Code (drift-validation, plugin-registration) im Test-pfad
|
|
72
|
+
* durchläuft, ohne dass Mollie-API-Calls passieren. */
|
|
73
|
+
readonly _clientShapeForTests?: MollieClientShape;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function createSubscriptionMollieFeature(
|
|
77
|
+
options: SubscriptionMollieOptions,
|
|
78
|
+
): FeatureDefinition {
|
|
79
|
+
if (options.apiKey.length === 0) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"subscription-mollie: apiKey is empty. Set MOLLIE_API_KEY (or system-config) before mounting.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (options.webhookUrl.length === 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"subscription-mollie: webhookUrl is empty. Set the foundation-webhook-URL where Mollie sends events.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Drift-Guard: priceToTier + priceToConfig müssen identische Keys
|
|
91
|
+
// haben — sonst silent-null-returns aus verifyAndParseWebhook.
|
|
92
|
+
const tierKeys = new Set(Object.keys(options.priceToTier));
|
|
93
|
+
const configKeys = new Set(Object.keys(options.priceToConfig));
|
|
94
|
+
const missingInConfig = [...tierKeys].filter((k) => !configKeys.has(k));
|
|
95
|
+
const missingInTier = [...configKeys].filter((k) => !tierKeys.has(k));
|
|
96
|
+
if (missingInConfig.length > 0 || missingInTier.length > 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`subscription-mollie: priceToTier ↔ priceToConfig drift — ` +
|
|
99
|
+
`priceIds in tier but missing config: [${missingInConfig.join(", ")}]; ` +
|
|
100
|
+
`priceIds in config but missing tier: [${missingInTier.join(", ")}]`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const client = createMollieClient({ apiKey: options.apiKey });
|
|
105
|
+
|
|
106
|
+
// Adapter um den Plugin's verify-fetch-client an den vollen Mollie-
|
|
107
|
+
// Client zu binden. Plugin-fetch-API ist minimal damit Tests ohne
|
|
108
|
+
// den vollen MollieClient mocken können.
|
|
109
|
+
const fetchAdapter: MollieClientShape = options._clientShapeForTests ?? {
|
|
110
|
+
payments: { get: (id: string) => client.payments.get(id) },
|
|
111
|
+
customerSubscriptions: {
|
|
112
|
+
get: (subId: string, customerId: string) =>
|
|
113
|
+
client.customerSubscriptions.get(subId, { customerId }),
|
|
114
|
+
list: async (customerId: string) => {
|
|
115
|
+
const page = await client.customerSubscriptions.page({ customerId });
|
|
116
|
+
return [...page];
|
|
117
|
+
},
|
|
118
|
+
create: (customerId, params) =>
|
|
119
|
+
client.customerSubscriptions.create({ customerId, ...params }),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const verifyAndParse = verifyAndParseMollieWebhook(fetchAdapter, {
|
|
124
|
+
priceToTier: options.priceToTier,
|
|
125
|
+
priceToConfig: options.priceToConfig,
|
|
126
|
+
});
|
|
127
|
+
const checkoutSession = createMollieCheckoutSession(
|
|
128
|
+
client,
|
|
129
|
+
options.priceToConfig,
|
|
130
|
+
options.webhookUrl,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return defineFeature(SUBSCRIPTION_MOLLIE_FEATURE, (r) => {
|
|
134
|
+
r.requires("billing-foundation");
|
|
135
|
+
|
|
136
|
+
const plugin: SubscriptionProviderPlugin = {
|
|
137
|
+
verifyAndParseWebhook: verifyAndParse,
|
|
138
|
+
createCheckoutSession: checkoutSession,
|
|
139
|
+
// createPortalSession + cancelSubscription bewusst nicht — siehe
|
|
140
|
+
// plugin-methods.ts für Begründung.
|
|
141
|
+
};
|
|
142
|
+
r.useExtension("subscriptionProvider", MOLLIE_PROVIDER_NAME, plugin);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Public API of the subscription-mollie bundled-feature.
|
|
2
|
+
//
|
|
3
|
+
// **Internal-only** (NICHT im public-barrel — App-Builder nutzt das nie direkt):
|
|
4
|
+
// - verifyAndParseMollieWebhook (intern vom feature.ts factory aufgerufen)
|
|
5
|
+
// - mapMollieEventType / mapMollieStatus / extractMollieId (pure helpers,
|
|
6
|
+
// test-only — direct-import aus dem File wenn echt mal extern gebraucht)
|
|
7
|
+
|
|
8
|
+
export { MOLLIE_PROVIDER_NAME, SUBSCRIPTION_MOLLIE_FEATURE } from "./constants";
|
|
9
|
+
export {
|
|
10
|
+
createSubscriptionMollieFeature,
|
|
11
|
+
type SubscriptionMollieOptions,
|
|
12
|
+
} from "./feature";
|
|
13
|
+
export type { MolliePriceConfig } from "./plugin-methods";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Mollie-Plugin-Methoden für die POST-tenant-resolution-Phase:
|
|
2
|
+
// createCheckoutSession (only). createPortalSession + cancelSubscription
|
|
3
|
+
// nicht implementiert weil Mollie's API die Patterns nicht 1:1 bietet:
|
|
4
|
+
//
|
|
5
|
+
// - **Portal:** Mollie hat keinen Customer-Portal — App-Builder UI muss
|
|
6
|
+
// das selbst rendern.
|
|
7
|
+
// - **Cancel:** Mollie braucht (customerId, subId), Plugin-Contract
|
|
8
|
+
// reicht aber nur subId durch — App-Builder cancelt via Mollie-
|
|
9
|
+
// Dashboard oder eigener Route bis Foundation-Contract erweitert ist.
|
|
10
|
+
//
|
|
11
|
+
// **Mollie-Checkout-Flow ist mehrstufig:** Customer anlegen → first-
|
|
12
|
+
// payment mit sequenceType="first" (= Mandate-setup) → User bezahlt →
|
|
13
|
+
// Mollie-webhook → verify-webhook erstellt die Mollie-Subscription
|
|
14
|
+
// idempotent (siehe verify-webhook.ts ensureSubscriptionForMandate).
|
|
15
|
+
|
|
16
|
+
import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
17
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import type { MollieClient, Payment } from "@mollie/api-client";
|
|
19
|
+
import { SequenceType } from "@mollie/api-client";
|
|
20
|
+
|
|
21
|
+
export type MollieCheckoutOptions = Parameters<
|
|
22
|
+
NonNullable<SubscriptionProviderPlugin["createCheckoutSession"]>
|
|
23
|
+
>[1];
|
|
24
|
+
|
|
25
|
+
/** Mollie hat keinen nativen price-id-Konzept — App-Builder pflegt
|
|
26
|
+
* pro virtuellem priceId einen amount/interval/description. */
|
|
27
|
+
export type MolliePriceConfig = {
|
|
28
|
+
/** Mollie-format: 2-decimal string, z.B. `"9.99"`. */
|
|
29
|
+
readonly amountValue: string;
|
|
30
|
+
readonly amountCurrency: string;
|
|
31
|
+
/** Mollie-format: `1 month` / `1 year` / `14 days`. */
|
|
32
|
+
readonly interval: string;
|
|
33
|
+
readonly description: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function createMollieCheckoutSession(
|
|
37
|
+
client: MollieClient,
|
|
38
|
+
priceToConfig: Readonly<Record<string, MolliePriceConfig>>,
|
|
39
|
+
appWebhookUrl: string,
|
|
40
|
+
) {
|
|
41
|
+
return async (_ctx: HandlerContext, options: MollieCheckoutOptions): Promise<{ url: string }> => {
|
|
42
|
+
const priceCfg = priceToConfig[options.priceId];
|
|
43
|
+
if (!priceCfg) {
|
|
44
|
+
throw new Error(`subscription-mollie: priceId "${options.priceId}" not in priceToConfig-Map`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let customerId = options.providerCustomerId;
|
|
48
|
+
if (!customerId) {
|
|
49
|
+
const customer = await client.customers.create({
|
|
50
|
+
metadata: { tenantId: options.tenantId },
|
|
51
|
+
});
|
|
52
|
+
customerId = customer.id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// payments.create ist overloaded (Promise OR void mit callback);
|
|
56
|
+
// explicit cast auf Promise<Payment>-overload.
|
|
57
|
+
const payment = (await (client.payments.create({
|
|
58
|
+
amount: { currency: priceCfg.amountCurrency, value: priceCfg.amountValue },
|
|
59
|
+
description: priceCfg.description,
|
|
60
|
+
sequenceType: SequenceType.first,
|
|
61
|
+
customerId,
|
|
62
|
+
redirectUrl: options.successUrl,
|
|
63
|
+
cancelUrl: options.cancelUrl,
|
|
64
|
+
webhookUrl: appWebhookUrl,
|
|
65
|
+
metadata: {
|
|
66
|
+
tenantId: options.tenantId,
|
|
67
|
+
priceId: options.priceId,
|
|
68
|
+
},
|
|
69
|
+
}) as Promise<Payment>)) satisfies Payment;
|
|
70
|
+
|
|
71
|
+
const checkoutHref = payment.getCheckoutUrl();
|
|
72
|
+
if (!checkoutHref) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"subscription-mollie: payment.getCheckoutUrl() returned null — first-payment-mandates ggf. nicht aktiviert",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { url: checkoutHref };
|
|
78
|
+
};
|
|
79
|
+
}
|