@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,39 @@
|
|
|
1
|
+
// Resolver-helper: liest die current subscription-row für einen Tenant
|
|
2
|
+
// aus der read_subscriptions-projection.
|
|
3
|
+
|
|
4
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import { subscriptionAggregateId } from "./aggregate-id";
|
|
7
|
+
import { subscriptionsProjectionTable } from "./projection";
|
|
8
|
+
|
|
9
|
+
export type SubscriptionView = {
|
|
10
|
+
readonly tier: string;
|
|
11
|
+
readonly status: string;
|
|
12
|
+
readonly providerName: string;
|
|
13
|
+
readonly providerCustomerId: string;
|
|
14
|
+
readonly providerSubscriptionId: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Liefert die einzige subscription-row für den Tenant (deterministic
|
|
18
|
+
* aggregate-id), oder null wenn der Tenant nie subscribed hat. Status
|
|
19
|
+
* kann active/canceled/past_due/etc sein — Caller entscheidet was tun. */
|
|
20
|
+
export async function getSubscriptionForTenant(
|
|
21
|
+
ctx: HandlerContext,
|
|
22
|
+
tenantId: string,
|
|
23
|
+
): Promise<SubscriptionView | null> {
|
|
24
|
+
const aggId = subscriptionAggregateId(tenantId);
|
|
25
|
+
const [row] = await ctx.db
|
|
26
|
+
.select()
|
|
27
|
+
.from(subscriptionsProjectionTable)
|
|
28
|
+
.where(eq(subscriptionsProjectionTable["id"], aggId))
|
|
29
|
+
.limit(1);
|
|
30
|
+
if (!row) return null;
|
|
31
|
+
// @cast-boundary db-row — drizzle-row carries column-as-unknown
|
|
32
|
+
return {
|
|
33
|
+
tier: row["tier"] as string,
|
|
34
|
+
status: row["status"] as string,
|
|
35
|
+
providerName: row["providerName"] as string,
|
|
36
|
+
providerCustomerId: row["providerCustomerId"] as string,
|
|
37
|
+
providerSubscriptionId: row["providerSubscriptionId"] as string,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// create-checkout-session — Tenant-Admin klickt "Upgrade to Pro" (oder
|
|
2
|
+
// wählt zwischen Karte/PayPal/Apple-Pay). Handler findet den
|
|
3
|
+
// providerName aus der payload, lookuppt den Plugin, ruft
|
|
4
|
+
// `plugin.createCheckoutSession(ctx, ...)`, returnt hosted-page-URL.
|
|
5
|
+
// Tenant-Admin wird dorthin redirected.
|
|
6
|
+
//
|
|
7
|
+
// **Multi-Provider-Pfad:** payload.providerName ist der entityName
|
|
8
|
+
// eines registrierten Plugins ("stripe" / "paypal" / "mollie" / ...).
|
|
9
|
+
// App-Builder zeigt eine UI-Liste der gemounteten provider, Endkunde
|
|
10
|
+
// pickt einen, dieser handler dispatched zum richtigen Plugin.
|
|
11
|
+
//
|
|
12
|
+
// **Tenant-resolution:** ctx.user.tenantId wird als metadata an den
|
|
13
|
+
// Provider mitgegeben. Beim subsequent webhook (subscription.created)
|
|
14
|
+
// liest verifyAndParseWebhook das aus dem provider-payload zurück und
|
|
15
|
+
// resolved den Tenant.
|
|
16
|
+
|
|
17
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { SUBSCRIPTION_PROVIDER_EXTENSION } from "../constants";
|
|
20
|
+
import type { SubscriptionProviderPlugin } from "../types";
|
|
21
|
+
|
|
22
|
+
const createCheckoutSessionSchema = z.object({
|
|
23
|
+
/** Welcher Provider — entityName eines registrierten subscription-
|
|
24
|
+
* Plugins ("stripe" / "paypal" / ...). */
|
|
25
|
+
providerName: z.string().min(1).max(50),
|
|
26
|
+
/** Provider-eigene price/plan-ID. */
|
|
27
|
+
priceId: z.string().min(1).max(200),
|
|
28
|
+
/** Wo der Endkunde nach erfolgreichem checkout landed. */
|
|
29
|
+
successUrl: z.string().url(),
|
|
30
|
+
/** Wo der Endkunde landed wenn er abbricht. */
|
|
31
|
+
cancelUrl: z.string().url(),
|
|
32
|
+
/** Optional: existierender provider-customer wenn der Tenant schon
|
|
33
|
+
* einen account beim Provider hat (= Plan-Wechsel statt Neuregistrierung). */
|
|
34
|
+
providerCustomerId: z.string().max(200).optional(),
|
|
35
|
+
});
|
|
36
|
+
type CreateCheckoutSessionPayload = z.infer<typeof createCheckoutSessionSchema>;
|
|
37
|
+
|
|
38
|
+
export const createCheckoutSessionHandler: WriteHandlerDef = {
|
|
39
|
+
name: "create-checkout-session",
|
|
40
|
+
schema: createCheckoutSessionSchema,
|
|
41
|
+
// Tenant-Admin-only — der Tenant muss bewusst seine Subscription
|
|
42
|
+
// konfigurieren. SystemAdmin als Fallback für Operator-Initiated-Flows.
|
|
43
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
44
|
+
handler: async (event, ctx) => {
|
|
45
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
46
|
+
const payload = event.payload as CreateCheckoutSessionPayload;
|
|
47
|
+
|
|
48
|
+
// Plugin-Lookup via registry. Dispatcher-context hat ctx.registry —
|
|
49
|
+
// wir suchen den entityName-match in den extension-usages.
|
|
50
|
+
const usages = ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION);
|
|
51
|
+
const usage = usages.find((u) => u.entityName === payload.providerName);
|
|
52
|
+
if (!usage) {
|
|
53
|
+
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
54
|
+
throw new Error(
|
|
55
|
+
`subscription-foundation: provider "${payload.providerName}" not registered. Known: ${known}.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
59
|
+
const plugin = usage.options as SubscriptionProviderPlugin;
|
|
60
|
+
if (!plugin.createCheckoutSession) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`subscription-foundation: provider "${payload.providerName}" has no createCheckoutSession-method (e.g. Apple-IAP-only providers). Use the provider's native checkout flow.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result = await plugin.createCheckoutSession(ctx, {
|
|
67
|
+
priceId: payload.priceId,
|
|
68
|
+
tenantId: event.user.tenantId,
|
|
69
|
+
successUrl: payload.successUrl,
|
|
70
|
+
cancelUrl: payload.cancelUrl,
|
|
71
|
+
...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
isSuccess: true as const,
|
|
76
|
+
data: { url: result.url, providerName: payload.providerName },
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// create-portal-session — Tenant-Admin klickt "Manage Subscription".
|
|
2
|
+
// Handler findet die current subscription des Tenants, lookuppt den
|
|
3
|
+
// passenden Plugin (= subscription.providerName-Spalte), ruft
|
|
4
|
+
// `plugin.createPortalSession(ctx, ...)`, returnt hosted-portal-URL.
|
|
5
|
+
//
|
|
6
|
+
// **Provider-resolution:** anders als create-checkout-session (= der
|
|
7
|
+
// Tenant wählt einen NEUEN Provider beim Subscribe) ist hier der
|
|
8
|
+
// Provider durch die existing subscription-row festgelegt — Tenant
|
|
9
|
+
// kann nicht zum Portal eines OTHER Providers, weil der ihn nicht
|
|
10
|
+
// kennt.
|
|
11
|
+
|
|
12
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { eq } from "drizzle-orm";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { subscriptionAggregateId } from "../aggregate-id";
|
|
16
|
+
import { SUBSCRIPTION_PROVIDER_EXTENSION } from "../constants";
|
|
17
|
+
import { subscriptionsProjectionTable as subTable } from "../projection";
|
|
18
|
+
import type { SubscriptionProviderPlugin } from "../types";
|
|
19
|
+
|
|
20
|
+
const createPortalSessionSchema = z.object({
|
|
21
|
+
/** Wo der Endkunde nach Portal-Session landed. */
|
|
22
|
+
returnUrl: z.string().url(),
|
|
23
|
+
});
|
|
24
|
+
type CreatePortalSessionPayload = z.infer<typeof createPortalSessionSchema>;
|
|
25
|
+
|
|
26
|
+
export const createPortalSessionHandler: WriteHandlerDef = {
|
|
27
|
+
name: "create-portal-session",
|
|
28
|
+
schema: createPortalSessionSchema,
|
|
29
|
+
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
30
|
+
handler: async (event, ctx) => {
|
|
31
|
+
const payload = event.payload as CreatePortalSessionPayload;
|
|
32
|
+
const tenantId = event.user.tenantId;
|
|
33
|
+
|
|
34
|
+
// 1. Hol current subscription-row für den Tenant. Aggregate-id ist
|
|
35
|
+
// deterministic per tenant — eine row pro tenant.
|
|
36
|
+
const subAggId = subscriptionAggregateId(tenantId);
|
|
37
|
+
const rows = await ctx.db.select().from(subTable).where(eq(subTable["id"], subAggId)).limit(1);
|
|
38
|
+
const row = rows[0];
|
|
39
|
+
if (!row) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"subscription-foundation: no active subscription for this tenant. Create one via create-checkout-session first.",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const providerName = row["providerName"] as string;
|
|
45
|
+
const providerCustomerId = row["providerCustomerId"] as string;
|
|
46
|
+
|
|
47
|
+
// 2. Plugin-Lookup
|
|
48
|
+
const usages = ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION);
|
|
49
|
+
const usage = usages.find((u) => u.entityName === providerName);
|
|
50
|
+
if (!usage) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`subscription-foundation: subscription belongs to provider "${providerName}" but the matching plugin is not mounted.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
// @cast-boundary engine-payload — extension-usage carries unknown options
|
|
56
|
+
const plugin = usage.options as SubscriptionProviderPlugin;
|
|
57
|
+
if (!plugin.createPortalSession) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`subscription-foundation: provider "${providerName}" has no createPortalSession-method (e.g. Apple-IAP managed Subs in der Apple-App).`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await plugin.createPortalSession(ctx, {
|
|
64
|
+
providerCustomerId,
|
|
65
|
+
returnUrl: payload.returnUrl,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
isSuccess: true as const,
|
|
70
|
+
data: { url: result.url, providerName },
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// list-subscriptions — sysadmin-cross-tenant list-query auf der
|
|
2
|
+
// `read_subscriptions`-projection. Tenant-Admins lesen ihre eigene
|
|
3
|
+
// subscription via getSubscriptionForTenant-helper (= ctx.db ist
|
|
4
|
+
// tenant-scoped, gibt automatisch nur die row des Callers zurück).
|
|
5
|
+
|
|
6
|
+
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { subscriptionsProjectionTable } from "../projection";
|
|
9
|
+
|
|
10
|
+
const listSchema = z.object({}).strict();
|
|
11
|
+
|
|
12
|
+
export const listSubscriptionsQuery: QueryHandlerDef = {
|
|
13
|
+
name: "subscription:list",
|
|
14
|
+
schema: listSchema,
|
|
15
|
+
access: { roles: ["SystemAdmin", "TenantAdmin"] },
|
|
16
|
+
handler: async (_query, ctx) => {
|
|
17
|
+
const rows = await ctx.db.select().from(subscriptionsProjectionTable);
|
|
18
|
+
return { rows };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// process-event — programmatic write-handler den der webhook-handler
|
|
2
|
+
// (createSubscriptionWebhookHandler) aufruft NACHDEM Plugin den raw-body
|
|
3
|
+
// verifiziert + zu SubscriptionEvent normalisiert hat.
|
|
4
|
+
//
|
|
5
|
+
// **ES-Pattern:**
|
|
6
|
+
// 1. Idempotency-check: lädt subscription-stream + scannt nach
|
|
7
|
+
// bereits gesehenem `metadata.providerEventId`. Provider-Replay
|
|
8
|
+
// (Stripe-Retry-Storm) sieht denselben event-id → duplicate=true,
|
|
9
|
+
// kein zweiter append.
|
|
10
|
+
// 2. Type-mapping: SubscriptionEvent.type (= normalisiert vom Plugin)
|
|
11
|
+
// → einer der 5 ES-event-typen.
|
|
12
|
+
// 3. ctx.appendEventUnsafe — Inline-projection materialisiert die
|
|
13
|
+
// `read_subscriptions`-row in derselben TX.
|
|
14
|
+
|
|
15
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { subscriptionAggregateId } from "../aggregate-id";
|
|
18
|
+
import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
|
|
19
|
+
import {
|
|
20
|
+
INVOICE_PAID_EVENT_QN,
|
|
21
|
+
INVOICE_PAYMENT_FAILED_EVENT_QN,
|
|
22
|
+
SUBSCRIPTION_AGGREGATE_TYPE,
|
|
23
|
+
SUBSCRIPTION_CANCELED_EVENT_QN,
|
|
24
|
+
SUBSCRIPTION_CREATED_EVENT_QN,
|
|
25
|
+
SUBSCRIPTION_UPDATED_EVENT_QN,
|
|
26
|
+
type SubscriptionEventHeaders,
|
|
27
|
+
type SubscriptionEventPayload,
|
|
28
|
+
} from "../events";
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Input-Schema = der normalisierte SubscriptionEvent (ohne tenantId, der
|
|
32
|
+
// kommt aus event.user.tenantId — der webhook-handler setzt den
|
|
33
|
+
// programmatic-user mit der vom Plugin aufgelösten tenantId).
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
const eventTypeSchema = z.enum([
|
|
37
|
+
SubscriptionEventTypes.created,
|
|
38
|
+
SubscriptionEventTypes.updated,
|
|
39
|
+
SubscriptionEventTypes.canceled,
|
|
40
|
+
SubscriptionEventTypes.invoicePaid,
|
|
41
|
+
SubscriptionEventTypes.invoicePaymentFailed,
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const statusSchema = z.enum([
|
|
45
|
+
SubscriptionStatuses.active,
|
|
46
|
+
SubscriptionStatuses.trialing,
|
|
47
|
+
SubscriptionStatuses.pastDue,
|
|
48
|
+
SubscriptionStatuses.canceled,
|
|
49
|
+
SubscriptionStatuses.incomplete,
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
export const processEventSchema = z.object({
|
|
53
|
+
providerEventId: z.string().min(1).max(200),
|
|
54
|
+
providerName: z.string().min(1).max(50),
|
|
55
|
+
type: eventTypeSchema,
|
|
56
|
+
providerCustomerId: z.string().min(1).max(200),
|
|
57
|
+
providerSubscriptionId: z.string().min(1).max(200),
|
|
58
|
+
status: statusSchema,
|
|
59
|
+
tier: z.string().min(1).max(50),
|
|
60
|
+
currentPeriodEndIso: z.string().min(1),
|
|
61
|
+
rawPayload: z.string().min(1),
|
|
62
|
+
});
|
|
63
|
+
type ProcessEventPayload = z.infer<typeof processEventSchema>;
|
|
64
|
+
|
|
65
|
+
// Map normalized SubscriptionEventType → fully-qualified ES event-name.
|
|
66
|
+
const NORMALIZED_TO_ES_EVENT: Readonly<Record<string, string>> = {
|
|
67
|
+
[SubscriptionEventTypes.created]: SUBSCRIPTION_CREATED_EVENT_QN,
|
|
68
|
+
[SubscriptionEventTypes.updated]: SUBSCRIPTION_UPDATED_EVENT_QN,
|
|
69
|
+
[SubscriptionEventTypes.canceled]: SUBSCRIPTION_CANCELED_EVENT_QN,
|
|
70
|
+
[SubscriptionEventTypes.invoicePaid]: INVOICE_PAID_EVENT_QN,
|
|
71
|
+
[SubscriptionEventTypes.invoicePaymentFailed]: INVOICE_PAYMENT_FAILED_EVENT_QN,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// Handler
|
|
76
|
+
// =============================================================================
|
|
77
|
+
//
|
|
78
|
+
// SystemAdmin-only: dieser handler wird ausschließlich vom programmatic
|
|
79
|
+
// webhook-handler aufgerufen (mit einem internal SystemUser), nie vom
|
|
80
|
+
// Tenant-Admin direkt.
|
|
81
|
+
export const processEventHandler: WriteHandlerDef = {
|
|
82
|
+
name: "process-event",
|
|
83
|
+
schema: processEventSchema,
|
|
84
|
+
access: { roles: ["SystemAdmin"] },
|
|
85
|
+
handler: async (event, ctx) => {
|
|
86
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
87
|
+
const payload = event.payload as ProcessEventPayload;
|
|
88
|
+
const tenantId = event.user.tenantId;
|
|
89
|
+
const aggId = subscriptionAggregateId(tenantId);
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------
|
|
92
|
+
// 1. Idempotency: load subscription-stream + check ob dieser
|
|
93
|
+
// providerEventId bereits gesehen wurde. Provider-Retry-Storm
|
|
94
|
+
// (Stripe sendet bis zu 5x in 4h) trifft denselben Stream und
|
|
95
|
+
// findet den event-id in metadata.
|
|
96
|
+
//
|
|
97
|
+
// **Performance-caveat:** O(N) pro stream. Bei 5 Jahren history
|
|
98
|
+
// (recurring monatlich = ~60 events) noch <50ms. Bei deutlich
|
|
99
|
+
// längeren streams optimieren via snapshot oder per-tenant
|
|
100
|
+
// dedup-table als idempotency-anchor (analog cap-counter).
|
|
101
|
+
// ---------------------------------------------------------------
|
|
102
|
+
const existingEvents = await ctx.loadAggregate(aggId);
|
|
103
|
+
const alreadySeen = existingEvents.some((e) => {
|
|
104
|
+
const headers = e.metadata.headers ?? {};
|
|
105
|
+
return (
|
|
106
|
+
headers["providerEventId"] === payload.providerEventId &&
|
|
107
|
+
headers["providerName"] === payload.providerName
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
if (alreadySeen) {
|
|
111
|
+
return {
|
|
112
|
+
isSuccess: true as const,
|
|
113
|
+
data: { duplicate: true as const, subscriptionAggregateId: aggId },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------
|
|
118
|
+
// 2. Map normalized event-type → ES event-FQN.
|
|
119
|
+
// ---------------------------------------------------------------
|
|
120
|
+
const esEventType = NORMALIZED_TO_ES_EVENT[payload.type];
|
|
121
|
+
if (!esEventType) {
|
|
122
|
+
// Schema-validation oben sollte das schon fangen, aber defensive
|
|
123
|
+
// gegen drift im SubscriptionEventTypes-enum vs NORMALIZED-Map.
|
|
124
|
+
throw new Error(`subscription-foundation: no ES event-type mapping for "${payload.type}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------
|
|
128
|
+
// 3. Append event auf den subscription-stream. Inline-projection
|
|
129
|
+
// materialisiert die read_subscriptions-row in derselben TX.
|
|
130
|
+
// ---------------------------------------------------------------
|
|
131
|
+
const eventPayload: SubscriptionEventPayload = {
|
|
132
|
+
providerName: payload.providerName,
|
|
133
|
+
providerCustomerId: payload.providerCustomerId,
|
|
134
|
+
providerSubscriptionId: payload.providerSubscriptionId,
|
|
135
|
+
status: payload.status,
|
|
136
|
+
tier: payload.tier,
|
|
137
|
+
currentPeriodEndIso: payload.currentPeriodEndIso,
|
|
138
|
+
};
|
|
139
|
+
const headers: SubscriptionEventHeaders = {
|
|
140
|
+
providerEventId: payload.providerEventId,
|
|
141
|
+
providerName: payload.providerName,
|
|
142
|
+
rawPayload: payload.rawPayload,
|
|
143
|
+
};
|
|
144
|
+
await ctx.appendEventUnsafe({
|
|
145
|
+
aggregateId: aggId,
|
|
146
|
+
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
147
|
+
type: esEventType,
|
|
148
|
+
payload: eventPayload,
|
|
149
|
+
headers,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
isSuccess: true as const,
|
|
154
|
+
data: {
|
|
155
|
+
duplicate: false as const,
|
|
156
|
+
subscriptionAggregateId: aggId,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Public API of the subscription-foundation bundled-feature.
|
|
2
|
+
|
|
3
|
+
export { subscriptionAggregateId } from "./aggregate-id";
|
|
4
|
+
export {
|
|
5
|
+
BILLING_FOUNDATION_FEATURE,
|
|
6
|
+
SUBSCRIPTION_PROVIDER_EXTENSION,
|
|
7
|
+
type SubscriptionEventType,
|
|
8
|
+
SubscriptionEventTypes,
|
|
9
|
+
SubscriptionFoundationHandlers,
|
|
10
|
+
SubscriptionFoundationQueries,
|
|
11
|
+
type SubscriptionStatus,
|
|
12
|
+
SubscriptionStatuses,
|
|
13
|
+
} from "./constants";
|
|
14
|
+
export { subscriptionEntity } from "./entities";
|
|
15
|
+
export {
|
|
16
|
+
INVOICE_PAID_EVENT_QN,
|
|
17
|
+
INVOICE_PAID_EVENT_SHORT,
|
|
18
|
+
INVOICE_PAYMENT_FAILED_EVENT_QN,
|
|
19
|
+
INVOICE_PAYMENT_FAILED_EVENT_SHORT,
|
|
20
|
+
SUBSCRIPTION_AGGREGATE_TYPE,
|
|
21
|
+
SUBSCRIPTION_CANCELED_EVENT_QN,
|
|
22
|
+
SUBSCRIPTION_CANCELED_EVENT_SHORT,
|
|
23
|
+
SUBSCRIPTION_CREATED_EVENT_QN,
|
|
24
|
+
SUBSCRIPTION_CREATED_EVENT_SHORT,
|
|
25
|
+
SUBSCRIPTION_UPDATED_EVENT_QN,
|
|
26
|
+
SUBSCRIPTION_UPDATED_EVENT_SHORT,
|
|
27
|
+
type SubscriptionEventHeaders,
|
|
28
|
+
type SubscriptionEventPayload,
|
|
29
|
+
subscriptionEventPayloadSchema,
|
|
30
|
+
} from "./events";
|
|
31
|
+
export { billingFoundationFeature } from "./feature";
|
|
32
|
+
export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscription-for-tenant";
|
|
33
|
+
export { subscriptionsProjectionTable } from "./projection";
|
|
34
|
+
export type {
|
|
35
|
+
SubscriptionEvent,
|
|
36
|
+
SubscriptionProviderPlugin,
|
|
37
|
+
} from "./types";
|
|
38
|
+
export {
|
|
39
|
+
createSubscriptionWebhookHandler,
|
|
40
|
+
type SubscriptionWebhookDeps,
|
|
41
|
+
type SubscriptionWebhookHandler,
|
|
42
|
+
} from "./webhook-handler";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Inline-projection für `read_subscriptions`. Materialisiert die 5
|
|
2
|
+
// subscription-events in eine row pro Tenant.
|
|
3
|
+
//
|
|
4
|
+
// Apply läuft in derselben TX wie ctx.appendEventUnsafe — Caller sieht
|
|
5
|
+
// seinen Schreib-State sofort (kein dispatcher-tick nötig). PK = event.
|
|
6
|
+
// aggregateId (= deterministic uuidv5 pro Tenant) → replays kollidieren
|
|
7
|
+
// auf der PK statt doppelte rows zu erzeugen.
|
|
8
|
+
//
|
|
9
|
+
// **Production-deployment caveat:** der Generator in
|
|
10
|
+
// `samples/apps/platform/drizzle/generate.ts` scant `feature.entities` —
|
|
11
|
+
// `subscriptionsProjectionTable` ist als raw drizzle-pgTable in der
|
|
12
|
+
// projection registriert, NICHT als r.entity. Apps die subscription-
|
|
13
|
+
// foundation production mounten müssen die Tabelle in ihre eigene
|
|
14
|
+
// `drizzle/generate.ts` ergänzen (= via subscriptionsProjectionTable-
|
|
15
|
+
// import). setupTestStack pusht sie automatisch via r.projection.table.
|
|
16
|
+
|
|
17
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
+
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { subscriptionEntity } from "./entities";
|
|
20
|
+
import type { SubscriptionEventPayload } from "./events";
|
|
21
|
+
|
|
22
|
+
// Drizzle-table-instance aus dem entity-shape. Wird sowohl von der
|
|
23
|
+
// projection-apply als auch von list-query / get-helper genutzt damit
|
|
24
|
+
// alle drei Stellen denselben column-namespace teilen.
|
|
25
|
+
export const subscriptionsProjectionTable = buildDrizzleTable("subscription", subscriptionEntity);
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Shared helpers
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/** Felder die alle 5 events vollständig zur Verfügung haben. */
|
|
32
|
+
function fullSetFromPayload(p: SubscriptionEventPayload) {
|
|
33
|
+
return {
|
|
34
|
+
providerName: p.providerName,
|
|
35
|
+
providerCustomerId: p.providerCustomerId,
|
|
36
|
+
providerSubscriptionId: p.providerSubscriptionId,
|
|
37
|
+
status: p.status,
|
|
38
|
+
tier: p.tier,
|
|
39
|
+
currentPeriodEnd: p.currentPeriodEndIso,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** UPSERT-helper für defensive apply: wenn die row nicht existiert
|
|
44
|
+
* (= z.B. Plugin sendet "updated" als ersten event eines streams,
|
|
45
|
+
* oder rebuild-aus-dem-Nichts), legen wir sie an statt fail-silent
|
|
46
|
+
* zu sein. Apply läuft in der event-TX, expectedVersion macht
|
|
47
|
+
* drizzle-on-conflict korrekt. */
|
|
48
|
+
async function upsert(
|
|
49
|
+
tx: Parameters<Parameters<typeof defineApply<SubscriptionEventPayload>>[0]>[1],
|
|
50
|
+
event: { aggregateId: string; tenantId: string },
|
|
51
|
+
set: Partial<{
|
|
52
|
+
providerName: string;
|
|
53
|
+
providerCustomerId: string;
|
|
54
|
+
providerSubscriptionId: string;
|
|
55
|
+
status: string;
|
|
56
|
+
tier: string;
|
|
57
|
+
currentPeriodEnd: string;
|
|
58
|
+
}>,
|
|
59
|
+
fullPayload: SubscriptionEventPayload,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
// INSERT-fallback braucht ALL fields (NOT NULL constraints). Wenn
|
|
62
|
+
// jemand nur teil-felder updated (z.B. invoice-payment-failed nur
|
|
63
|
+
// status+tier), nutzen wir trotzdem den vollen payload für den
|
|
64
|
+
// INSERT-Pfad und nur den teil-`set` für ON CONFLICT.
|
|
65
|
+
await tx
|
|
66
|
+
.insert(subscriptionsProjectionTable)
|
|
67
|
+
.values({
|
|
68
|
+
id: event.aggregateId,
|
|
69
|
+
tenantId: event.tenantId,
|
|
70
|
+
...fullSetFromPayload(fullPayload),
|
|
71
|
+
})
|
|
72
|
+
.onConflictDoUpdate({
|
|
73
|
+
target: subscriptionsProjectionTable["id"],
|
|
74
|
+
set,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Apply-functions — eine pro event-typ
|
|
80
|
+
//
|
|
81
|
+
// Alle UPSERT für defensive consistency: ein out-of-order event
|
|
82
|
+
// (z.B. rebuild-from-events) kann in jeder Reihenfolge ankommen
|
|
83
|
+
// und die row korrekt materialisieren.
|
|
84
|
+
// =============================================================================
|
|
85
|
+
|
|
86
|
+
/** subscription-created → UPSERT mit allen Feldern. PK = aggregateId =
|
|
87
|
+
* subscriptionAggregateId(tenantId), one row pro Tenant. UPSERT damit
|
|
88
|
+
* Disney+-Wechsel-Pattern (= zweiter Provider sendet create für selben
|
|
89
|
+
* Tenant) den existing row überschreibt statt PK-conflict. */
|
|
90
|
+
export const applySubscriptionCreated = defineApply<SubscriptionEventPayload>(async (event, tx) => {
|
|
91
|
+
const full = fullSetFromPayload(event.payload);
|
|
92
|
+
await upsert(tx, event, full, event.payload);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** subscription-updated → UPSERT mit allen Feldern. */
|
|
96
|
+
export const applySubscriptionUpdated = defineApply<SubscriptionEventPayload>(async (event, tx) => {
|
|
97
|
+
const full = fullSetFromPayload(event.payload);
|
|
98
|
+
await upsert(tx, event, full, event.payload);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** subscription-canceled → status/tier/currentPeriodEnd patchen. */
|
|
102
|
+
export const applySubscriptionCanceled = defineApply<SubscriptionEventPayload>(
|
|
103
|
+
async (event, tx) => {
|
|
104
|
+
const p = event.payload;
|
|
105
|
+
await upsert(
|
|
106
|
+
tx,
|
|
107
|
+
event,
|
|
108
|
+
{ status: p.status, tier: p.tier, currentPeriodEnd: p.currentPeriodEndIso },
|
|
109
|
+
p,
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/** invoice-paid → state-update (status, tier, currentPeriodEnd).
|
|
115
|
+
* Invoice-history selbst lebt im event-store (= Replay-fähig). */
|
|
116
|
+
export const applyInvoicePaid = defineApply<SubscriptionEventPayload>(async (event, tx) => {
|
|
117
|
+
const p = event.payload;
|
|
118
|
+
await upsert(
|
|
119
|
+
tx,
|
|
120
|
+
event,
|
|
121
|
+
{ status: p.status, tier: p.tier, currentPeriodEnd: p.currentPeriodEndIso },
|
|
122
|
+
p,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** invoice-payment-failed → status (typisch past_due) + tier. tier-
|
|
127
|
+
* engine liest die row + entscheidet ob downgrade. currentPeriodEnd
|
|
128
|
+
* bewusst nicht — die Period ist noch nicht "vorbei", payment hat
|
|
129
|
+
* nur nicht geklappt. */
|
|
130
|
+
export const applyInvoicePaymentFailed = defineApply<SubscriptionEventPayload>(
|
|
131
|
+
async (event, tx) => {
|
|
132
|
+
const p = event.payload;
|
|
133
|
+
await upsert(tx, event, { status: p.status, tier: p.tier }, p);
|
|
134
|
+
},
|
|
135
|
+
);
|