@cosmicdrift/kumiko-bundled-features 0.287.0 → 0.288.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.288.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -132,12 +132,12 @@
|
|
|
132
132
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
133
133
|
},
|
|
134
134
|
"dependencies": {
|
|
135
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
135
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.288.0",
|
|
136
|
+
"@cosmicdrift/kumiko-framework": "0.288.0",
|
|
137
|
+
"@cosmicdrift/kumiko-headless": "0.288.0",
|
|
138
|
+
"@cosmicdrift/kumiko-renderer": "0.288.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer-web": "0.288.0",
|
|
140
|
+
"@cosmicdrift/kumiko-types": "0.288.0",
|
|
141
141
|
"@mollie/api-client": "^4.5.0",
|
|
142
142
|
"@node-rs/argon2": "^2.0.2",
|
|
143
143
|
"@types/mailparser": "^3.4.6",
|
|
@@ -166,8 +166,8 @@
|
|
|
166
166
|
],
|
|
167
167
|
"devDependencies": {
|
|
168
168
|
"@testing-library/user-event": "^14.6.1",
|
|
169
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
170
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
169
|
+
"@cosmicdrift/kumiko-locale-de": "0.288.0",
|
|
170
|
+
"@cosmicdrift/kumiko-locale-es": "0.288.0",
|
|
171
171
|
"jsqr": "^1.4.0"
|
|
172
172
|
}
|
|
173
173
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Integration test for createSubscriptionTierSync()'s onSyncError option
|
|
2
|
+
// (#3027). Forces a sync failure via a dispatchSystemWrite stub that fails
|
|
3
|
+
// only the tier-engine handlers (the primary billing-foundation writes run
|
|
4
|
+
// for real against the test DB) — proving that "log" (default) still
|
|
5
|
+
// answers the webhook successfully, while "fail-webhook" surfaces the sync
|
|
6
|
+
// error all the way into the HTTP response.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
10
|
+
import { buildEntityTable, type EntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import {
|
|
12
|
+
defineFeature,
|
|
13
|
+
type EntityDefinition,
|
|
14
|
+
type TenantId,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
createTestUser,
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
testTenantId,
|
|
22
|
+
unsafeCreateEntityTable,
|
|
23
|
+
unsafePushTables,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
|
+
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
26
|
+
import { Hono } from "hono";
|
|
27
|
+
import {
|
|
28
|
+
createComplianceProfilesFeature,
|
|
29
|
+
tenantComplianceProfileEntity,
|
|
30
|
+
} from "../../compliance-profiles";
|
|
31
|
+
import { createConfigFeature } from "../../config";
|
|
32
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
33
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
34
|
+
import type { TierMap } from "../../tier-engine/compose-app";
|
|
35
|
+
import { TierEngineHandlers } from "../../tier-engine/constants";
|
|
36
|
+
import { tierAssignmentEntity } from "../../tier-engine/entity";
|
|
37
|
+
import { createTierEngineFeature } from "../../tier-engine/feature";
|
|
38
|
+
import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
|
|
39
|
+
import { billingFoundationFeature } from "../feature";
|
|
40
|
+
import {
|
|
41
|
+
createSubscriptionTierSync,
|
|
42
|
+
SUBSCRIPTION_WEBHOOK_PATH,
|
|
43
|
+
type SystemWriteResult,
|
|
44
|
+
} from "../subscription-tier-sync";
|
|
45
|
+
import type { SubscriptionEvent, SubscriptionProviderPlugin } from "../types";
|
|
46
|
+
|
|
47
|
+
type TestTier = "free" | "pro";
|
|
48
|
+
const isTierName = (v: string): v is TestTier => v === "free" || v === "pro";
|
|
49
|
+
const TEST_TIER_MAP: TierMap<{ readonly maxItems: number }> = {
|
|
50
|
+
free: { features: [], caps: { maxItems: 1 } },
|
|
51
|
+
pro: { features: [], caps: { maxItems: 100 } },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const mockProviderFeature = defineFeature("test-mock-tier-sync-provider", (r) => {
|
|
55
|
+
r.requires("billing-foundation");
|
|
56
|
+
const plugin: SubscriptionProviderPlugin = {
|
|
57
|
+
verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent,
|
|
58
|
+
};
|
|
59
|
+
r.useExtension("subscriptionProvider", "mock", plugin);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Boundary-cast: SubscriptionTierSyncDeps deliberately erases the entity's
|
|
63
|
+
// concrete field types to EntityDefinition (generic factory), which TS
|
|
64
|
+
// can't structurally widen to on its own.
|
|
65
|
+
const tierAssignmentTable = buildEntityTable(
|
|
66
|
+
"tier-assignment",
|
|
67
|
+
tierAssignmentEntity,
|
|
68
|
+
) as unknown as EntityTable<EntityDefinition>;
|
|
69
|
+
|
|
70
|
+
let stack: TestStack;
|
|
71
|
+
let forceTierSyncFailure = false;
|
|
72
|
+
|
|
73
|
+
async function dispatchSystemWrite(args: {
|
|
74
|
+
readonly handlerQn: string;
|
|
75
|
+
readonly payload: unknown;
|
|
76
|
+
readonly tenantId: TenantId;
|
|
77
|
+
}): Promise<SystemWriteResult> {
|
|
78
|
+
if (
|
|
79
|
+
forceTierSyncFailure &&
|
|
80
|
+
(args.handlerQn === TierEngineHandlers.create || args.handlerQn === TierEngineHandlers.update)
|
|
81
|
+
) {
|
|
82
|
+
return {
|
|
83
|
+
isSuccess: false,
|
|
84
|
+
error: { code: "test_forced_failure", message: "forced tier-sync failure" },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const systemUser = createTestUser({ id: 1, tenantId: args.tenantId, roles: ["SystemAdmin"] });
|
|
88
|
+
const res = await stack.http.write(args.handlerQn, args.payload, systemUser);
|
|
89
|
+
const body = (await res.json()) as {
|
|
90
|
+
isSuccess: boolean;
|
|
91
|
+
data?: unknown;
|
|
92
|
+
error?: { readonly code?: string; readonly message?: string };
|
|
93
|
+
};
|
|
94
|
+
return body.isSuccess
|
|
95
|
+
? { isSuccess: true, data: body.data }
|
|
96
|
+
: { isSuccess: false, error: body.error };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let logApp: Hono;
|
|
100
|
+
let failApp: Hono;
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
stack = await setupTestStack({
|
|
104
|
+
features: [
|
|
105
|
+
createConfigFeature(),
|
|
106
|
+
createTenantFeature(),
|
|
107
|
+
createComplianceProfilesFeature(),
|
|
108
|
+
createTenantLifecycleFeature(),
|
|
109
|
+
billingFoundationFeature,
|
|
110
|
+
createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP }),
|
|
111
|
+
mockProviderFeature,
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
await createEventsTable(stack.db);
|
|
115
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
116
|
+
await unsafePushTables(stack.db, { tier_assignments: tierAssignmentTable });
|
|
117
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
118
|
+
|
|
119
|
+
const sharedDeps = {
|
|
120
|
+
db: stack.db,
|
|
121
|
+
registry: stack.registry,
|
|
122
|
+
dispatchSystemWrite,
|
|
123
|
+
tierAssignmentTable,
|
|
124
|
+
isTierName,
|
|
125
|
+
defaultTier: "free" as const,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
logApp = new Hono();
|
|
129
|
+
createSubscriptionTierSync<TestTier>(sharedDeps).wireSubscriptionWebhookRoute(logApp);
|
|
130
|
+
|
|
131
|
+
failApp = new Hono();
|
|
132
|
+
createSubscriptionTierSync<TestTier>({
|
|
133
|
+
...sharedDeps,
|
|
134
|
+
onSyncError: "fail-webhook",
|
|
135
|
+
}).wireSubscriptionWebhookRoute(failApp);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterAll(async () => {
|
|
139
|
+
await stack.cleanup();
|
|
140
|
+
resetPiiSubjectKmsForTests();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const webhookPath = SUBSCRIPTION_WEBHOOK_PATH.replace(":providerName", "mock");
|
|
144
|
+
|
|
145
|
+
function buildEvent(tenantId: string, providerEventId: string): SubscriptionEvent {
|
|
146
|
+
return {
|
|
147
|
+
providerEventId,
|
|
148
|
+
providerName: "mock",
|
|
149
|
+
type: SubscriptionEventTypes.created,
|
|
150
|
+
tenantId,
|
|
151
|
+
providerCustomerId: `cus_${providerEventId}`,
|
|
152
|
+
providerSubscriptionId: `sub_${providerEventId}`,
|
|
153
|
+
status: SubscriptionStatuses.active,
|
|
154
|
+
tier: "pro",
|
|
155
|
+
currentPeriodEnd: "2026-06-01T00:00:00Z",
|
|
156
|
+
rawPayload: "{}",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
describe("createSubscriptionTierSync onSyncError", () => {
|
|
161
|
+
test("default ('log'): a tier-sync failure still returns a successful webhook response", async () => {
|
|
162
|
+
forceTierSyncFailure = true;
|
|
163
|
+
const tenantId = testTenantId(9001);
|
|
164
|
+
const res = await logApp.request(webhookPath, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
body: JSON.stringify(buildEvent(tenantId, "evt_log_1")),
|
|
167
|
+
});
|
|
168
|
+
forceTierSyncFailure = false;
|
|
169
|
+
|
|
170
|
+
expect(res.status).toBe(200);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("'fail-webhook': a tier-sync failure also fails the webhook response", async () => {
|
|
174
|
+
forceTierSyncFailure = true;
|
|
175
|
+
const tenantId = testTenantId(9002);
|
|
176
|
+
const res = await failApp.request(webhookPath, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
body: JSON.stringify(buildEvent(tenantId, "evt_fail_1")),
|
|
179
|
+
});
|
|
180
|
+
forceTierSyncFailure = false;
|
|
181
|
+
|
|
182
|
+
expect(res.status).toBe(500);
|
|
183
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
184
|
+
expect(body.error.code).toBe("subscription_webhook_processing_failed");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.288.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "add onSyncError option to createSubscriptionTierSync",
|
|
6
|
+
"detail": "`SubscriptionTierSyncDeps<TTier>` gets an optional `onSyncError?: \"log\" | \"fail-webhook\"`\n(default `\"log\"`, matching prior behavior exactly). `\"fail-webhook\"` makes a tier-sync\nfailure also fail the webhook response, so an idempotent-retry caller (whose retry\nno-ops the already-committed primary write) gets another attempt at the sync step."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.286.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -35,6 +35,12 @@ export type SubscriptionTierSyncDeps<TTier extends string> = {
|
|
|
35
35
|
readonly tierAssignmentTable: EntityTable<EntityDefinition>;
|
|
36
36
|
readonly isTierName: (value: string) => value is TTier;
|
|
37
37
|
readonly defaultTier: TTier;
|
|
38
|
+
// "log" (default): sync failure only warns, the webhook still reports
|
|
39
|
+
// success — right when the caller has no idempotent retry to lean on.
|
|
40
|
+
// "fail-webhook": sync failure fails the webhook response too, so an
|
|
41
|
+
// idempotent caller (e.g. Stripe, whose retry re-runs the already-committed
|
|
42
|
+
// primary write as a no-op) gets a second attempt at the sync step itself.
|
|
43
|
+
readonly onSyncError?: "log" | "fail-webhook";
|
|
38
44
|
};
|
|
39
45
|
|
|
40
46
|
export function effectiveTierFromSubscription<TTier extends string>(
|
|
@@ -116,16 +122,15 @@ export function createSubscriptionTierSync<TTier extends string>(
|
|
|
116
122
|
tenantId: targetTenantId,
|
|
117
123
|
});
|
|
118
124
|
if (!result.isSuccess) return result;
|
|
119
|
-
// The primary write already committed — a webhook caller (Stripe/
|
|
120
|
-
// PayPal) that sees isSuccess:false here retries the whole event,
|
|
121
|
-
// re-running an already-succeeded side effect. Log the tier-sync
|
|
122
|
-
// failure instead of masking the primary write's success.
|
|
123
125
|
const syncError = await syncTierFromSubscription(targetTenantId);
|
|
124
126
|
if (syncError) {
|
|
125
127
|
// biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
|
|
126
128
|
console.warn(
|
|
127
129
|
`[subscription-tier-sync] tier sync failed for tenant ${targetTenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
|
|
128
130
|
);
|
|
131
|
+
if (deps.onSyncError === "fail-webhook") {
|
|
132
|
+
return { isSuccess: false, error: syncError };
|
|
133
|
+
}
|
|
129
134
|
}
|
|
130
135
|
return result;
|
|
131
136
|
},
|