@warpgogol/werkstatt-shared 0.13.0 → 0.14.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 +21 -69
- package/src/integration/funnel.ts +78 -1
- package/src/integration/index.ts +19 -2
- package/src/integration/lagebild-ingress.ts +169 -0
- package/src/integration/orchestration.ts +7 -13
- package/src/integration/port-barrel.ts +9 -28
- package/src/integration/tests/funnel-bridge.test.ts +7 -7
- package/src/integration/tests/lagebild-ingress.test.ts +118 -0
- package/src/observability/typed-refs.ts +1 -1
- package/src/ontology/analytics/matomo-fleet.registry.yaml +1 -1
- package/src/ontology/archetypes/index.json +12 -9
- package/src/ontology/archetypes/index.yaml +26 -10
- package/src/ontology/blueprints/website-local.yaml +11 -11
- package/src/ontology/blueprints/website-service.yaml +20 -20
- package/src/ontology/capabilities/lead.submit.yaml +2 -2
- package/src/ontology/enums.ts +2 -2
- package/src/ontology/external-surfaces/jsonld-types.yaml +14 -14
- package/src/ontology/external-surfaces/url-schema.yaml +1 -1
- package/src/ontology/index.ts +2 -0
- package/src/ontology/schemas/index.ts +7 -2
- package/src/ontology/schemas/system/integrations.ts +1 -1
- package/src/ontology/schemas/system/manifest.ts +38 -1
- package/src/ontology/schemas/system.ts +7 -2
- package/src/ontology/shared-section-props/common.ts +11 -0
- package/src/ontology/shared-section-props/visual-header.ts +1 -1
- package/src/share/agent/api-catalog.ts +5 -0
- package/src/share/agent/ard-catalog.ts +14 -0
- package/src/share/agent/index.ts +2 -0
- package/src/share/agent/manifest.ts +25 -2
- package/src/share/agent/mcp-card.ts +4 -0
- package/src/share/agent/openapi.ts +78 -0
- package/src/share/agent/search.test.ts +188 -0
- package/src/share/agent/search.ts +164 -0
- package/src/share/env.d.ts +0 -4
- package/src/share/schemas/effects.ts +16 -0
- package/src/share/schemas/media.ts +4 -0
- package/src/share/schemas/section-shell.ts +2 -1
- package/src/share/scripts/external-link-qr.ts +9 -1
- package/src/share/scripts/gsap-counter.ts +2 -0
- package/src/share/scripts/inline-number-animation.ts +2 -0
- package/src/share/scripts/live-photos.ts +2 -0
- package/src/share/scripts/video-player.test.ts +210 -0
- package/src/share/scripts/video-player.ts +73 -13
- package/src/share/semantic/extract.ts +25 -1
- package/src/share/semantic/tests/split-sentences.test.ts +57 -0
- package/src/share/tests/manifest.test.ts +51 -0
- package/src/share/tests/openapi.test.ts +30 -0
- package/src/share/types/qrcode-browser.d.ts +18 -0
- package/src/share/utility-registry.yaml +2 -2
- package/src/integration/crm-buffer.ts +0 -491
- package/src/integration-adapter-supabase-crm/adapter.ts +0 -437
- package/src/integration-adapter-supabase-crm/client.ts +0 -605
- package/src/integration-adapter-supabase-crm/index.ts +0 -28
- package/src/integration-adapter-supabase-crm/pipedrive-sync-target.ts +0 -482
- package/src/integration-adapter-supabase-crm/supabase-rest.ts +0 -89
- package/src/integration-adapter-supabase-crm/tenant-registry.ts +0 -334
- package/src/integration-adapter-supabase-crm/tests/change-balance.pbt.test.ts +0 -83
- package/src/integration-adapter-supabase-crm/tests/funnel-persistence.test.ts +0 -409
- package/src/integration-adapter-supabase-crm/tests/lifecycle-sync.test.ts +0 -288
- package/src/integration-adapter-supabase-crm/tests/stage-map.test.ts +0 -80
- package/src/integration-adapter-supabase-crm/vitest.config.ts +0 -8
- package/src/integration-adapter-supabase-crm/worker.ts +0 -223
- package/src/ontology/archetypes/components/currency-selector.yaml +0 -19
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
<MODULE_CONTRACT>
|
|
3
|
-
<purpose>RFC-0188 Phase 4: prove the supabase-buffer adapter writes the rich canonical funnel
|
|
4
|
-
state — funnel_stage (bridged to the generic stage), the deal-time offer snapshot, the typed
|
|
5
|
-
append-only funnel-event row, and append-only consent evidence — and that plain leads keep the
|
|
6
|
-
back-compatible generic path. The CrmBufferClient is faked; no network.</purpose>
|
|
7
|
-
<responsibilities>
|
|
8
|
-
<item>offer.selected → deal.funnel_stage + bridged stage + offer_snapshot + funnel-event row; no consent.</item>
|
|
9
|
-
<item>legal.consent.recorded → append-only consent row with the right consent_kind.</item>
|
|
10
|
-
<item>plain lead → no funnel-event, generic stage, no funnel_stage.</item>
|
|
11
|
-
</responsibilities>
|
|
12
|
-
<non-goals><item>No network — the buffer client is a capturing fake.</item></non-goals>
|
|
13
|
-
</MODULE_CONTRACT>
|
|
14
|
-
<CHANGE_SUMMARY><item>RFC-0188 Phase 4: initial adapter funnel-persistence test.</item></CHANGE_SUMMARY>
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { test, expect } from "vitest";
|
|
18
|
-
import type { IntegrationEvent } from "@warpgogol/werkstatt-shared/integration/port";
|
|
19
|
-
import type { CrmBufferWriter } from "@warpgogol/werkstatt-shared/integration/crm-buffer";
|
|
20
|
-
import { persistEventToBuffer } from "../adapter.ts";
|
|
21
|
-
|
|
22
|
-
function makeFakeClient() {
|
|
23
|
-
const calls = {
|
|
24
|
-
deals: [] as unknown[],
|
|
25
|
-
funnelEvents: [] as unknown[],
|
|
26
|
-
consents: [] as unknown[],
|
|
27
|
-
orgs: [] as unknown[],
|
|
28
|
-
transitions: [] as unknown[],
|
|
29
|
-
outbox: [] as unknown[],
|
|
30
|
-
invoices: [] as unknown[],
|
|
31
|
-
subs: [] as unknown[],
|
|
32
|
-
balanceAdjusts: [] as unknown[],
|
|
33
|
-
};
|
|
34
|
-
const stubs: {
|
|
35
|
-
org: { id: string } | null;
|
|
36
|
-
sub: {
|
|
37
|
-
id: string;
|
|
38
|
-
included_changes_per_cycle: number;
|
|
39
|
-
included_changes_balance: number;
|
|
40
|
-
} | null;
|
|
41
|
-
activeSub: { id: string; included_changes_balance: number } | null;
|
|
42
|
-
} = { org: null, sub: null, activeSub: null };
|
|
43
|
-
const client: CrmBufferWriter = {
|
|
44
|
-
upsertContact: async () => ({ id: "contact_1", created: true }),
|
|
45
|
-
upsertOrganization: async (_t, d) => {
|
|
46
|
-
calls.orgs.push(d);
|
|
47
|
-
return { id: "org_1", created: true };
|
|
48
|
-
},
|
|
49
|
-
findOrganizationByStripeCustomer: async () => stubs.org,
|
|
50
|
-
upsertDeal: async (_t, d) => {
|
|
51
|
-
calls.deals.push(d);
|
|
52
|
-
return { id: "deal_1", created: true };
|
|
53
|
-
},
|
|
54
|
-
appendStageTransition: async (_t, d) => {
|
|
55
|
-
calls.transitions.push(d);
|
|
56
|
-
return "tr_1";
|
|
57
|
-
},
|
|
58
|
-
appendFunnelEvent: async (_t, d) => {
|
|
59
|
-
calls.funnelEvents.push(d);
|
|
60
|
-
return "fe_1";
|
|
61
|
-
},
|
|
62
|
-
appendConsentEvent: async (_t, d) => {
|
|
63
|
-
calls.consents.push(d);
|
|
64
|
-
return "ce_1";
|
|
65
|
-
},
|
|
66
|
-
upsertSubscription: async (_t, d) => {
|
|
67
|
-
calls.subs.push(d);
|
|
68
|
-
return { id: "sub_1", created: true };
|
|
69
|
-
},
|
|
70
|
-
findSubscriptionByStripeId: async () => stubs.sub,
|
|
71
|
-
findActiveSubscriptionByOrganization: async () => stubs.activeSub,
|
|
72
|
-
appendInvoice: async (_t, d) => {
|
|
73
|
-
calls.invoices.push(d);
|
|
74
|
-
return "inv_1";
|
|
75
|
-
},
|
|
76
|
-
adjustChangeBalance: async (_t, subscriptionId, delta) => {
|
|
77
|
-
calls.balanceAdjusts.push({ subscriptionId, delta });
|
|
78
|
-
return delta;
|
|
79
|
-
},
|
|
80
|
-
writeOutbox: async (_t, tasks) => {
|
|
81
|
-
calls.outbox.push(...tasks);
|
|
82
|
-
return { ids: [] };
|
|
83
|
-
},
|
|
84
|
-
};
|
|
85
|
-
return { client, calls, stubs };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
test("offer.selected: writes funnel_stage, bridged stage, offer snapshot, and a funnel-event row", async () => {
|
|
89
|
-
const { client, calls } = makeFakeClient();
|
|
90
|
-
const event: IntegrationEvent = {
|
|
91
|
-
eventId: "uchat:offer.selected:1",
|
|
92
|
-
kind: "message",
|
|
93
|
-
source: "uchat",
|
|
94
|
-
locale: "de",
|
|
95
|
-
occurredAt: "2026-06-12T10:00:00Z",
|
|
96
|
-
contact: { name: "Anna" },
|
|
97
|
-
payload: {
|
|
98
|
-
funnelVersion: "1.0.0",
|
|
99
|
-
eventKind: "offer.selected",
|
|
100
|
-
stage: "offer_presented",
|
|
101
|
-
previousStage: "qualification_region",
|
|
102
|
-
organization: { name: "Bäckerei Müller" },
|
|
103
|
-
offer: { plan: "digital_foundation_monthly", priceSnapshot: { monthly: "70 € / Monat" } },
|
|
104
|
-
},
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
await persistEventToBuffer(client, "t1", event);
|
|
108
|
-
|
|
109
|
-
expect(calls.deals[0].funnel_stage).toBe("offer_presented");
|
|
110
|
-
expect(calls.deals[0].stage).toBe("proposal"); // bridge offer_presented → proposal
|
|
111
|
-
expect(calls.deals[0].offer_snapshot.monthly).toBe("70 € / Monat");
|
|
112
|
-
expect(calls.deals[0].organization_id).toBe("org_1");
|
|
113
|
-
expect(calls.orgs[0].name).toBe("Bäckerei Müller");
|
|
114
|
-
expect(calls.funnelEvents.length).toBe(1);
|
|
115
|
-
expect(calls.funnelEvents[0].event_kind).toBe("offer.selected");
|
|
116
|
-
expect(calls.funnelEvents[0].idempotency_key).toBe("uchat:offer.selected:1");
|
|
117
|
-
expect(calls.funnelEvents[0].to_stage).toBe("offer_presented");
|
|
118
|
-
expect(calls.funnelEvents[0].actor).toBe("uchat");
|
|
119
|
-
expect(calls.consents.length).toBe(0);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("legal.consent.recorded (business): appends a B2B consent row", async () => {
|
|
123
|
-
const { client, calls } = makeFakeClient();
|
|
124
|
-
const event: IntegrationEvent = {
|
|
125
|
-
eventId: "uchat:legal.consent.recorded:1",
|
|
126
|
-
kind: "message",
|
|
127
|
-
source: "uchat",
|
|
128
|
-
locale: "de",
|
|
129
|
-
occurredAt: "2026-06-12T10:05:00Z",
|
|
130
|
-
payload: {
|
|
131
|
-
funnelVersion: "1.0.0",
|
|
132
|
-
eventKind: "legal.consent.recorded",
|
|
133
|
-
stage: "start_approved",
|
|
134
|
-
previousStage: "b2b_start_consent_pending",
|
|
135
|
-
legal: { buyerType: "business", startBeforeWithdrawalPeriod: true },
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
await persistEventToBuffer(client, "t1", event);
|
|
140
|
-
|
|
141
|
-
expect(calls.consents.length).toBe(1);
|
|
142
|
-
expect(calls.consents[0].buyer_type).toBe("business");
|
|
143
|
-
expect(calls.consents[0].consent_kind).toBe("b2b_start_before_completion");
|
|
144
|
-
expect(calls.consents[0].start_before_withdrawal_period).toBe(true);
|
|
145
|
-
expect(calls.deals[0].funnel_stage).toBe("start_approved");
|
|
146
|
-
expect(calls.deals[0].stage).toBe("negotiation"); // bridge
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("lifecycle invoice.paid: appends an invoice against the resolved Organization (no funnel deal)", async () => {
|
|
150
|
-
const { client, calls } = makeFakeClient();
|
|
151
|
-
const event: IntegrationEvent = {
|
|
152
|
-
eventId: "evt_in_1",
|
|
153
|
-
kind: "message",
|
|
154
|
-
source: "stripe",
|
|
155
|
-
locale: "de",
|
|
156
|
-
occurredAt: "2026-06-12T11:00:00Z",
|
|
157
|
-
payload: { eventKind: "invoice.paid", stripeCustomerId: "cus_1" },
|
|
158
|
-
lifecycle: {
|
|
159
|
-
eventKind: "invoice.paid",
|
|
160
|
-
stripeEventId: "evt_in_1",
|
|
161
|
-
stripeCustomerId: "cus_1",
|
|
162
|
-
stripeInvoiceId: "in_1",
|
|
163
|
-
invoiceKind: "cycle",
|
|
164
|
-
amountCents: 7000,
|
|
165
|
-
currency: "EUR",
|
|
166
|
-
lagebildOrganizationId: "org_9",
|
|
167
|
-
lagebildDealId: "deal_1",
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const res = await persistEventToBuffer(client, "t1", event);
|
|
172
|
-
|
|
173
|
-
expect(res).toBeTruthy();
|
|
174
|
-
expect(calls.invoices.length).toBe(1);
|
|
175
|
-
expect(calls.invoices[0].organization_id).toBe("org_9");
|
|
176
|
-
expect(calls.invoices[0].stripe_invoice_id).toBe("in_1");
|
|
177
|
-
expect(calls.invoices[0].kind).toBe("cycle");
|
|
178
|
-
expect(calls.invoices[0].amount_cents).toBe(7000);
|
|
179
|
-
expect(calls.invoices[0].status).toBe("paid");
|
|
180
|
-
expect(calls.invoices[0].paid_at).toBe("2026-06-12T11:00:00Z");
|
|
181
|
-
// The funnel deal flow is NOT taken for a lifecycle event.
|
|
182
|
-
expect(calls.deals.length).toBe(0);
|
|
183
|
-
expect(calls.funnelEvents.length).toBe(0);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test("lifecycle subscription.created: upserts the subscription with balance = per-cycle allowance", async () => {
|
|
187
|
-
const { client, calls } = makeFakeClient();
|
|
188
|
-
const event: IntegrationEvent = {
|
|
189
|
-
eventId: "evt_sub_c",
|
|
190
|
-
kind: "message",
|
|
191
|
-
source: "stripe",
|
|
192
|
-
locale: "de",
|
|
193
|
-
occurredAt: "2026-06-12T11:00:00Z",
|
|
194
|
-
payload: { eventKind: "subscription.created", stripeCustomerId: "cus_1" },
|
|
195
|
-
lifecycle: {
|
|
196
|
-
eventKind: "subscription.created",
|
|
197
|
-
stripeEventId: "evt_sub_c",
|
|
198
|
-
stripeCustomerId: "cus_1",
|
|
199
|
-
stripeSubscriptionId: "sub_x",
|
|
200
|
-
subscriptionStatus: "active",
|
|
201
|
-
plan: "digital_foundation_monthly",
|
|
202
|
-
mrrCents: 7000,
|
|
203
|
-
currency: "EUR",
|
|
204
|
-
lagebildOrganizationId: "org_9",
|
|
205
|
-
lagebildDealId: "deal_p3",
|
|
206
|
-
includedChangesPerCycle: 1,
|
|
207
|
-
},
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
await persistEventToBuffer(client, "t1", event);
|
|
211
|
-
|
|
212
|
-
expect(calls.subs.length).toBe(1);
|
|
213
|
-
expect(calls.subs[0].stripe_subscription_id).toBe("sub_x");
|
|
214
|
-
expect(calls.subs[0].plan).toBe("digital_foundation_monthly");
|
|
215
|
-
expect(calls.subs[0].mrr_cents).toBe(7000);
|
|
216
|
-
expect(calls.subs[0].included_changes_per_cycle).toBe(1);
|
|
217
|
-
expect(calls.subs[0].included_changes_balance).toBe(1); // set on create
|
|
218
|
-
expect(calls.invoices.length).toBe(0);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test("lifecycle subscription.updated: omits included_changes_balance (preserves mid-cycle decrements)", async () => {
|
|
222
|
-
const { client, calls } = makeFakeClient();
|
|
223
|
-
const event: IntegrationEvent = {
|
|
224
|
-
eventId: "evt_sub_u",
|
|
225
|
-
kind: "message",
|
|
226
|
-
source: "stripe",
|
|
227
|
-
locale: "de",
|
|
228
|
-
occurredAt: "2026-06-12T11:00:00Z",
|
|
229
|
-
payload: { eventKind: "subscription.updated", stripeCustomerId: "cus_1" },
|
|
230
|
-
lifecycle: {
|
|
231
|
-
eventKind: "subscription.updated",
|
|
232
|
-
stripeEventId: "evt_sub_u",
|
|
233
|
-
stripeCustomerId: "cus_1",
|
|
234
|
-
stripeSubscriptionId: "sub_x",
|
|
235
|
-
subscriptionStatus: "active",
|
|
236
|
-
plan: "digital_foundation_monthly",
|
|
237
|
-
mrrCents: 9900,
|
|
238
|
-
lagebildOrganizationId: "org_9",
|
|
239
|
-
lagebildDealId: "deal_p3",
|
|
240
|
-
includedChangesPerCycle: 1,
|
|
241
|
-
},
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
await persistEventToBuffer(client, "t1", event);
|
|
245
|
-
|
|
246
|
-
expect(calls.subs.length).toBe(1);
|
|
247
|
-
expect(calls.subs[0].mrr_cents).toBe(9900);
|
|
248
|
-
expect("included_changes_balance" in calls.subs[0]).toBe(false); // omitted on update
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("lifecycle paid cycle invoice resets the included-changes balance to per-cycle", async () => {
|
|
252
|
-
const { client, calls, stubs } = makeFakeClient();
|
|
253
|
-
stubs.sub = { id: "sub_buf_1", included_changes_per_cycle: 1, included_changes_balance: 0 };
|
|
254
|
-
const event: IntegrationEvent = {
|
|
255
|
-
eventId: "evt_cycle",
|
|
256
|
-
kind: "message",
|
|
257
|
-
source: "stripe",
|
|
258
|
-
locale: "de",
|
|
259
|
-
occurredAt: "2026-06-12T11:00:00Z",
|
|
260
|
-
payload: { eventKind: "invoice.paid", stripeCustomerId: "cus_1" },
|
|
261
|
-
lifecycle: {
|
|
262
|
-
eventKind: "invoice.paid",
|
|
263
|
-
stripeEventId: "evt_cycle",
|
|
264
|
-
stripeCustomerId: "cus_1",
|
|
265
|
-
stripeSubscriptionId: "sub_x",
|
|
266
|
-
stripeInvoiceId: "in_cycle",
|
|
267
|
-
invoiceKind: "cycle",
|
|
268
|
-
amountCents: 7000,
|
|
269
|
-
lagebildOrganizationId: "org_9",
|
|
270
|
-
},
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
await persistEventToBuffer(client, "t1", event);
|
|
274
|
-
|
|
275
|
-
expect(calls.invoices.length).toBe(1);
|
|
276
|
-
expect(calls.balanceAdjusts.length).toBe(1);
|
|
277
|
-
expect(calls.balanceAdjusts[0].subscriptionId).toBe("sub_buf_1");
|
|
278
|
-
expect(calls.balanceAdjusts[0].delta).toBe(1); // reset 0 → 1
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
test("lifecycle with an unknown Stripe Customer → soft no-op (null), no invoice", async () => {
|
|
282
|
-
const { client, calls } = makeFakeClient(); // findOrganizationByStripeCustomer returns null
|
|
283
|
-
const event: IntegrationEvent = {
|
|
284
|
-
eventId: "evt_in_2",
|
|
285
|
-
kind: "message",
|
|
286
|
-
source: "stripe",
|
|
287
|
-
locale: "de",
|
|
288
|
-
occurredAt: "2026-06-12T11:05:00Z",
|
|
289
|
-
payload: { eventKind: "invoice.paid", stripeCustomerId: "cus_unknown" },
|
|
290
|
-
lifecycle: {
|
|
291
|
-
eventKind: "invoice.paid",
|
|
292
|
-
stripeEventId: "evt_in_2",
|
|
293
|
-
stripeCustomerId: "cus_unknown",
|
|
294
|
-
stripeInvoiceId: "in_2",
|
|
295
|
-
amountCents: 1500,
|
|
296
|
-
},
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
const res = await persistEventToBuffer(client, "t1", event);
|
|
300
|
-
|
|
301
|
-
expect(res).toBe(null);
|
|
302
|
-
expect(calls.invoices.length).toBe(0);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
test("funnel change.requested (described, included): decrements the subscription balance", async () => {
|
|
306
|
-
const { client, calls, stubs } = makeFakeClient();
|
|
307
|
-
stubs.activeSub = { id: "sub_buf_1", included_changes_balance: 1 };
|
|
308
|
-
const event: IntegrationEvent = {
|
|
309
|
-
eventId: "uchat:change.requested:1",
|
|
310
|
-
kind: "message",
|
|
311
|
-
source: "uchat",
|
|
312
|
-
locale: "de",
|
|
313
|
-
occurredAt: "2026-06-12T12:00:00Z",
|
|
314
|
-
payload: {
|
|
315
|
-
funnelVersion: "1.0.0",
|
|
316
|
-
eventKind: "change.requested",
|
|
317
|
-
stage: "change_description_requested",
|
|
318
|
-
organization: { name: "Bäckerei Müller" },
|
|
319
|
-
changeRequest: { description: "Neue Öffnungszeiten" },
|
|
320
|
-
},
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
await persistEventToBuffer(client, "t1", event);
|
|
324
|
-
|
|
325
|
-
expect(calls.balanceAdjusts.length).toBe(1);
|
|
326
|
-
expect(calls.balanceAdjusts[0].subscriptionId).toBe("sub_buf_1");
|
|
327
|
-
expect(calls.balanceAdjusts[0].delta).toBe(-1);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
test("funnel change.requested with no included balance (or at balance-check): no decrement", async () => {
|
|
331
|
-
const { client, calls, stubs } = makeFakeClient();
|
|
332
|
-
stubs.activeSub = { id: "sub_buf_1", included_changes_balance: 0 }; // exhausted → paid path
|
|
333
|
-
const described: IntegrationEvent = {
|
|
334
|
-
eventId: "uchat:change.requested:2",
|
|
335
|
-
kind: "message",
|
|
336
|
-
source: "uchat",
|
|
337
|
-
locale: "de",
|
|
338
|
-
occurredAt: "2026-06-12T12:00:00Z",
|
|
339
|
-
payload: {
|
|
340
|
-
eventKind: "change.requested",
|
|
341
|
-
stage: "change_description_requested",
|
|
342
|
-
organization: { name: "Org" },
|
|
343
|
-
},
|
|
344
|
-
};
|
|
345
|
-
await persistEventToBuffer(client, "t1", described);
|
|
346
|
-
expect(calls.balanceAdjusts.length).toBe(0); // balance 0 → no decrement
|
|
347
|
-
|
|
348
|
-
// and at the balance-check stage there is no decrement even with balance > 0
|
|
349
|
-
stubs.activeSub = { id: "sub_buf_1", included_changes_balance: 1 };
|
|
350
|
-
const checked: IntegrationEvent = {
|
|
351
|
-
...described,
|
|
352
|
-
eventId: "uchat:change.requested:3",
|
|
353
|
-
payload: {
|
|
354
|
-
eventKind: "change.requested",
|
|
355
|
-
stage: "change_balance_checked",
|
|
356
|
-
organization: { name: "Org" },
|
|
357
|
-
},
|
|
358
|
-
};
|
|
359
|
-
await persistEventToBuffer(client, "t1", checked);
|
|
360
|
-
expect(calls.balanceAdjusts.length).toBe(0);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
test("lifecycle payment.refunded: records a negative void ad-hoc ledger entry", async () => {
|
|
364
|
-
const { client, calls } = makeFakeClient();
|
|
365
|
-
const event: IntegrationEvent = {
|
|
366
|
-
eventId: "evt_refund",
|
|
367
|
-
kind: "message",
|
|
368
|
-
source: "stripe",
|
|
369
|
-
locale: "de",
|
|
370
|
-
occurredAt: "2026-06-12T12:30:00Z",
|
|
371
|
-
payload: { eventKind: "payment.refunded", stripeCustomerId: "cus_1" },
|
|
372
|
-
lifecycle: {
|
|
373
|
-
eventKind: "payment.refunded",
|
|
374
|
-
stripeEventId: "evt_refund",
|
|
375
|
-
stripeCustomerId: "cus_1",
|
|
376
|
-
stripeInvoiceId: "ch_1",
|
|
377
|
-
amountCents: 1500,
|
|
378
|
-
currency: "EUR",
|
|
379
|
-
lagebildOrganizationId: "org_9",
|
|
380
|
-
},
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
await persistEventToBuffer(client, "t1", event);
|
|
384
|
-
|
|
385
|
-
expect(calls.invoices.length).toBe(1);
|
|
386
|
-
expect(calls.invoices[0].amount_cents).toBe(-1500);
|
|
387
|
-
expect(calls.invoices[0].status).toBe("void");
|
|
388
|
-
expect(calls.invoices[0].kind).toBe("adhoc");
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
test("plain lead (no eventKind): generic path, no funnel-event, no funnel_stage", async () => {
|
|
392
|
-
const { client, calls } = makeFakeClient();
|
|
393
|
-
const event: IntegrationEvent = {
|
|
394
|
-
eventId: "lead_1",
|
|
395
|
-
kind: "lead",
|
|
396
|
-
source: "send-message",
|
|
397
|
-
locale: "de",
|
|
398
|
-
occurredAt: "2026-06-12T10:10:00Z",
|
|
399
|
-
payload: { message: "Hi, I want a website" },
|
|
400
|
-
};
|
|
401
|
-
|
|
402
|
-
await persistEventToBuffer(client, "t1", event);
|
|
403
|
-
|
|
404
|
-
expect(calls.funnelEvents.length).toBe(0);
|
|
405
|
-
expect(calls.consents.length).toBe(0);
|
|
406
|
-
expect(calls.deals[0].funnel_stage).toBe(undefined);
|
|
407
|
-
expect(calls.deals[0].stage).toBe("new"); // lead default
|
|
408
|
-
expect(calls.orgs.length).toBe(0); // no organization in the payload
|
|
409
|
-
});
|
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
<MODULE_CONTRACT>
|
|
3
|
-
<purpose>RFC-0386: Unit tests for lifecycle sync (syncSubscription, syncInvoice).
|
|
4
|
-
Verifies P3 deal creation/linking, stage moves, invoice recording, change-balance
|
|
5
|
-
reset on paid cycles, and P4 change-deal opening. All tests use stubbed buffer
|
|
6
|
-
and fetch — no network calls.</purpose>
|
|
7
|
-
<non-goals>
|
|
8
|
-
<item>No network calls — all Pipedrive API calls are stubbed.</item>
|
|
9
|
-
<item>No real Supabase — buffer reads are stubbed via a fake CrmBufferReader.</item>
|
|
10
|
-
</non-goals>
|
|
11
|
-
</MODULE_CONTRACT>
|
|
12
|
-
<CHANGE_SUMMARY>
|
|
13
|
-
<item>RFC-0386: Initial lifecycle sync unit tests.</item>
|
|
14
|
-
</CHANGE_SUMMARY>
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { test, expect, vi } from "vitest";
|
|
18
|
-
import type {
|
|
19
|
-
CrmBufferReader,
|
|
20
|
-
SyncOutboxRow,
|
|
21
|
-
BufferSubscription,
|
|
22
|
-
BufferInvoice,
|
|
23
|
-
BufferOrganization,
|
|
24
|
-
} from "@warpgogol/werkstatt-shared/integration/crm-buffer";
|
|
25
|
-
import { PipedriveSyncTarget } from "../pipedrive-sync-target.ts";
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Stubs
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
function makeFakeBuffer(
|
|
32
|
-
overrides: {
|
|
33
|
-
subscription?: Partial<BufferSubscription> & { id: string };
|
|
34
|
-
invoice?: Partial<BufferInvoice> & { id: string };
|
|
35
|
-
organization?: Partial<BufferOrganization> & { id: string };
|
|
36
|
-
} = {},
|
|
37
|
-
): CrmBufferReader {
|
|
38
|
-
const sub: BufferSubscription = {
|
|
39
|
-
id: overrides.subscription?.id ?? "sub-1",
|
|
40
|
-
tenant_id: "t1",
|
|
41
|
-
organization_id: overrides.subscription?.organization_id ?? "org-1",
|
|
42
|
-
deal_id: overrides.subscription?.deal_id ?? "deal-1",
|
|
43
|
-
stripe_subscription_id: overrides.subscription?.stripe_subscription_id ?? "sub_stripe_123",
|
|
44
|
-
status: overrides.subscription?.status ?? "active",
|
|
45
|
-
plan: overrides.subscription?.plan ?? "base",
|
|
46
|
-
mrr_cents: overrides.subscription?.mrr_cents ?? 7000,
|
|
47
|
-
currency: overrides.subscription?.currency ?? "EUR",
|
|
48
|
-
included_changes_balance: overrides.subscription?.included_changes_balance ?? 3,
|
|
49
|
-
included_changes_per_cycle: overrides.subscription?.included_changes_per_cycle ?? 5,
|
|
50
|
-
pipedrive_deal_id: overrides.subscription?.pipedrive_deal_id,
|
|
51
|
-
created_at: "2026-01-01T00:00:00Z",
|
|
52
|
-
updated_at: "2026-01-01T00:00:00Z",
|
|
53
|
-
...overrides.subscription,
|
|
54
|
-
} as BufferSubscription;
|
|
55
|
-
|
|
56
|
-
const inv: BufferInvoice = {
|
|
57
|
-
id: overrides.invoice?.id ?? "inv-1",
|
|
58
|
-
tenant_id: "t1",
|
|
59
|
-
organization_id: overrides.invoice?.organization_id ?? "org-1",
|
|
60
|
-
stripe_invoice_id: overrides.invoice?.stripe_invoice_id ?? "in_stripe_456",
|
|
61
|
-
kind: overrides.invoice?.kind ?? "cycle",
|
|
62
|
-
amount_cents: overrides.invoice?.amount_cents ?? 7000,
|
|
63
|
-
currency: overrides.invoice?.currency ?? "EUR",
|
|
64
|
-
status: overrides.invoice?.status ?? "paid",
|
|
65
|
-
paid_at: overrides.invoice?.paid_at ?? "2026-01-01T00:00:00Z",
|
|
66
|
-
subscription_id: overrides.invoice?.subscription_id ?? "sub-1",
|
|
67
|
-
created_at: "2026-01-01T00:00:00Z",
|
|
68
|
-
...overrides.invoice,
|
|
69
|
-
} as BufferInvoice;
|
|
70
|
-
|
|
71
|
-
const org: BufferOrganization = {
|
|
72
|
-
id: overrides.organization?.id ?? "org-1",
|
|
73
|
-
tenant_id: "t1",
|
|
74
|
-
name: "Test Org",
|
|
75
|
-
pipedrive_org_id: overrides.organization?.pipedrive_org_id ?? 999,
|
|
76
|
-
created_at: "2026-01-01T00:00:00Z",
|
|
77
|
-
updated_at: "2026-01-01T00:00:00Z",
|
|
78
|
-
...overrides.organization,
|
|
79
|
-
} as BufferOrganization;
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
readPendingOutbox: vi.fn(),
|
|
83
|
-
updateOutboxStatus: vi.fn(),
|
|
84
|
-
getContact: vi.fn(),
|
|
85
|
-
getDeal: vi.fn(),
|
|
86
|
-
getOrganization: vi.fn(async (_t: string, _id: string) => org),
|
|
87
|
-
patchContactPipedriveId: vi.fn(),
|
|
88
|
-
patchOrganizationPipedriveId: vi.fn(),
|
|
89
|
-
patchDealPipedriveIds: vi.fn(),
|
|
90
|
-
getSubscription: vi.fn(async (_t: string, _id: string) => sub),
|
|
91
|
-
getInvoice: vi.fn(async (_t: string, _id: string) => inv),
|
|
92
|
-
patchSubscriptionPipedriveDealId: vi.fn(),
|
|
93
|
-
adjustChangeBalance: vi.fn(async () => 5),
|
|
94
|
-
} as unknown as CrmBufferReader;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function makeTask(
|
|
98
|
-
op: "upsert_subscription" | "upsert_invoice",
|
|
99
|
-
payload: Record<string, unknown>,
|
|
100
|
-
): SyncOutboxRow {
|
|
101
|
-
return {
|
|
102
|
-
id: "task-1",
|
|
103
|
-
tenant_id: "t1",
|
|
104
|
-
op,
|
|
105
|
-
payload,
|
|
106
|
-
status: "pending",
|
|
107
|
-
scheduled_at: "2026-01-01T00:00:00Z",
|
|
108
|
-
retry_count: 0,
|
|
109
|
-
max_retries: 5,
|
|
110
|
-
created_at: "2026-01-01T00:00:00Z",
|
|
111
|
-
updated_at: "2026-01-01T00:00:00Z",
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function makeFetchStub(): typeof fetch & {
|
|
116
|
-
calls: Array<{ url: string; method: string; body?: unknown }>;
|
|
117
|
-
} {
|
|
118
|
-
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
|
|
119
|
-
const stub = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
120
|
-
const urlStr = typeof url === "string" ? url : url.toString();
|
|
121
|
-
const method = init?.method ?? "GET";
|
|
122
|
-
let body: unknown;
|
|
123
|
-
if (init?.body) {
|
|
124
|
-
try {
|
|
125
|
-
body = JSON.parse(init.body as string);
|
|
126
|
-
} catch {
|
|
127
|
-
body = init.body;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
calls.push({ url: urlStr, method, body });
|
|
131
|
-
return new Response(JSON.stringify({ data: { id: 42 } }), {
|
|
132
|
-
status: 200,
|
|
133
|
-
headers: { "Content-Type": "application/json" },
|
|
134
|
-
});
|
|
135
|
-
}) as unknown as typeof fetch & { calls: Array<{ url: string; method: string; body?: unknown }> };
|
|
136
|
-
stub.calls = calls;
|
|
137
|
-
return stub;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
// syncSubscription
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
|
|
144
|
-
test("syncSubscription creates P3 deal when no pipedrive_deal_id exists", async () => {
|
|
145
|
-
const buffer = makeFakeBuffer({ subscription: { id: "sub-1", pipedrive_deal_id: undefined } });
|
|
146
|
-
const fetchStub = makeFetchStub();
|
|
147
|
-
const target = new PipedriveSyncTarget(
|
|
148
|
-
{ token: "tok", domain: "dom", p3StageMap: { active: 10, past_due: 11, canceled: 12 } },
|
|
149
|
-
fetchStub,
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
await target.syncSubscription(
|
|
153
|
-
makeTask("upsert_subscription", { subscription_id: "sub-1" }),
|
|
154
|
-
buffer,
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
expect(fetchStub.calls).toHaveLength(1);
|
|
158
|
-
expect(fetchStub.calls[0].method).toBe("POST");
|
|
159
|
-
expect(fetchStub.calls[0].body).toMatchObject({ stage_id: 10 });
|
|
160
|
-
expect(buffer.patchSubscriptionPipedriveDealId).toHaveBeenCalledWith("t1", "sub-1", 42);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("syncSubscription moves P3 deal stage when pipedrive_deal_id exists", async () => {
|
|
164
|
-
const buffer = makeFakeBuffer({
|
|
165
|
-
subscription: { id: "sub-1", status: "past_due", pipedrive_deal_id: 99 },
|
|
166
|
-
});
|
|
167
|
-
const fetchStub = makeFetchStub();
|
|
168
|
-
const target = new PipedriveSyncTarget(
|
|
169
|
-
{ token: "tok", domain: "dom", p3StageMap: { active: 10, past_due: 11, canceled: 12 } },
|
|
170
|
-
fetchStub,
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
await target.syncSubscription(
|
|
174
|
-
makeTask("upsert_subscription", { subscription_id: "sub-1" }),
|
|
175
|
-
buffer,
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
expect(fetchStub.calls).toHaveLength(1);
|
|
179
|
-
expect(fetchStub.calls[0].method).toBe("PUT");
|
|
180
|
-
expect(fetchStub.calls[0].url).toContain("/deals/99");
|
|
181
|
-
expect(fetchStub.calls[0].body).toMatchObject({ stage_id: 11 });
|
|
182
|
-
expect(buffer.patchSubscriptionPipedriveDealId).not.toHaveBeenCalled();
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("syncSubscription throws on missing subscription_id", async () => {
|
|
186
|
-
const buffer = makeFakeBuffer();
|
|
187
|
-
const target = new PipedriveSyncTarget({ token: "tok", domain: "dom" }, makeFetchStub());
|
|
188
|
-
|
|
189
|
-
await expect(
|
|
190
|
-
target.syncSubscription(makeTask("upsert_subscription", {}), buffer),
|
|
191
|
-
).rejects.toThrow("missing subscription_id");
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// ---------------------------------------------------------------------------
|
|
195
|
-
// syncInvoice
|
|
196
|
-
// ---------------------------------------------------------------------------
|
|
197
|
-
|
|
198
|
-
test("syncInvoice resets change balance on paid cycle invoice", async () => {
|
|
199
|
-
const buffer = makeFakeBuffer({
|
|
200
|
-
subscription: {
|
|
201
|
-
id: "sub-1",
|
|
202
|
-
included_changes_balance: 2,
|
|
203
|
-
included_changes_per_cycle: 5,
|
|
204
|
-
pipedrive_deal_id: 99,
|
|
205
|
-
},
|
|
206
|
-
invoice: { id: "inv-1", kind: "cycle", status: "paid", subscription_id: "sub-1" },
|
|
207
|
-
});
|
|
208
|
-
const fetchStub = makeFetchStub();
|
|
209
|
-
const target = new PipedriveSyncTarget({ token: "tok", domain: "dom" }, fetchStub);
|
|
210
|
-
|
|
211
|
-
await target.syncInvoice(makeTask("upsert_invoice", { invoice_id: "inv-1" }), buffer);
|
|
212
|
-
|
|
213
|
-
// delta = 5 - 2 = 3
|
|
214
|
-
expect(buffer.adjustChangeBalance).toHaveBeenCalledWith("t1", "sub-1", 3);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("syncInvoice does not reset balance on non-paid invoice", async () => {
|
|
218
|
-
const buffer = makeFakeBuffer({
|
|
219
|
-
subscription: {
|
|
220
|
-
id: "sub-1",
|
|
221
|
-
included_changes_balance: 2,
|
|
222
|
-
included_changes_per_cycle: 5,
|
|
223
|
-
pipedrive_deal_id: 99,
|
|
224
|
-
},
|
|
225
|
-
invoice: { id: "inv-1", kind: "cycle", status: "open", subscription_id: "sub-1" },
|
|
226
|
-
});
|
|
227
|
-
const fetchStub = makeFetchStub();
|
|
228
|
-
const target = new PipedriveSyncTarget({ token: "tok", domain: "dom" }, fetchStub);
|
|
229
|
-
|
|
230
|
-
await target.syncInvoice(makeTask("upsert_invoice", { invoice_id: "inv-1" }), buffer);
|
|
231
|
-
|
|
232
|
-
expect(buffer.adjustChangeBalance).not.toHaveBeenCalled();
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
test("syncInvoice does not reset balance on non-cycle invoice", async () => {
|
|
236
|
-
const buffer = makeFakeBuffer({
|
|
237
|
-
subscription: {
|
|
238
|
-
id: "sub-1",
|
|
239
|
-
included_changes_balance: 2,
|
|
240
|
-
included_changes_per_cycle: 5,
|
|
241
|
-
pipedrive_deal_id: 99,
|
|
242
|
-
},
|
|
243
|
-
invoice: { id: "inv-1", kind: "setup", status: "paid", subscription_id: "sub-1" },
|
|
244
|
-
});
|
|
245
|
-
const fetchStub = makeFetchStub();
|
|
246
|
-
const target = new PipedriveSyncTarget({ token: "tok", domain: "dom" }, fetchStub);
|
|
247
|
-
|
|
248
|
-
await target.syncInvoice(makeTask("upsert_invoice", { invoice_id: "inv-1" }), buffer);
|
|
249
|
-
|
|
250
|
-
expect(buffer.adjustChangeBalance).not.toHaveBeenCalled();
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test("syncInvoice opens P4 change deal for change invoice", async () => {
|
|
254
|
-
const buffer = makeFakeBuffer({
|
|
255
|
-
subscription: { id: "sub-1", pipedrive_deal_id: 99 },
|
|
256
|
-
invoice: { id: "inv-1", kind: "change", status: "paid", subscription_id: "sub-1" },
|
|
257
|
-
});
|
|
258
|
-
const fetchStub = makeFetchStub();
|
|
259
|
-
const target = new PipedriveSyncTarget(
|
|
260
|
-
{ token: "tok", domain: "dom", p4StageMap: { change_requested: 20 } },
|
|
261
|
-
fetchStub,
|
|
262
|
-
);
|
|
263
|
-
|
|
264
|
-
await target.syncInvoice(makeTask("upsert_invoice", { invoice_id: "inv-1" }), buffer);
|
|
265
|
-
|
|
266
|
-
// First call: note on P3 deal. Second call: P4 deal creation.
|
|
267
|
-
expect(fetchStub.calls.length).toBeGreaterThanOrEqual(2);
|
|
268
|
-
const p4Call = fetchStub.calls.find((c) => {
|
|
269
|
-
const b = c.body as Record<string, unknown> | undefined;
|
|
270
|
-
return (
|
|
271
|
-
c.method === "POST" &&
|
|
272
|
-
c.url.endsWith("/deals") &&
|
|
273
|
-
typeof b?.title === "string" &&
|
|
274
|
-
b.title.startsWith("P4")
|
|
275
|
-
);
|
|
276
|
-
});
|
|
277
|
-
expect(p4Call).toBeDefined();
|
|
278
|
-
expect(p4Call!.body).toMatchObject({ stage_id: 20 });
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
test("syncInvoice throws on missing invoice_id", async () => {
|
|
282
|
-
const buffer = makeFakeBuffer();
|
|
283
|
-
const target = new PipedriveSyncTarget({ token: "tok", domain: "dom" }, makeFetchStub());
|
|
284
|
-
|
|
285
|
-
await expect(target.syncInvoice(makeTask("upsert_invoice", {}), buffer)).rejects.toThrow(
|
|
286
|
-
"missing invoice_id",
|
|
287
|
-
);
|
|
288
|
-
});
|