@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.
Files changed (63) hide show
  1. package/package.json +21 -69
  2. package/src/integration/funnel.ts +78 -1
  3. package/src/integration/index.ts +19 -2
  4. package/src/integration/lagebild-ingress.ts +169 -0
  5. package/src/integration/orchestration.ts +7 -13
  6. package/src/integration/port-barrel.ts +9 -28
  7. package/src/integration/tests/funnel-bridge.test.ts +7 -7
  8. package/src/integration/tests/lagebild-ingress.test.ts +118 -0
  9. package/src/observability/typed-refs.ts +1 -1
  10. package/src/ontology/analytics/matomo-fleet.registry.yaml +1 -1
  11. package/src/ontology/archetypes/index.json +12 -9
  12. package/src/ontology/archetypes/index.yaml +26 -10
  13. package/src/ontology/blueprints/website-local.yaml +11 -11
  14. package/src/ontology/blueprints/website-service.yaml +20 -20
  15. package/src/ontology/capabilities/lead.submit.yaml +2 -2
  16. package/src/ontology/enums.ts +2 -2
  17. package/src/ontology/external-surfaces/jsonld-types.yaml +14 -14
  18. package/src/ontology/external-surfaces/url-schema.yaml +1 -1
  19. package/src/ontology/index.ts +2 -0
  20. package/src/ontology/schemas/index.ts +7 -2
  21. package/src/ontology/schemas/system/integrations.ts +1 -1
  22. package/src/ontology/schemas/system/manifest.ts +38 -1
  23. package/src/ontology/schemas/system.ts +7 -2
  24. package/src/ontology/shared-section-props/common.ts +11 -0
  25. package/src/ontology/shared-section-props/visual-header.ts +1 -1
  26. package/src/share/agent/api-catalog.ts +5 -0
  27. package/src/share/agent/ard-catalog.ts +14 -0
  28. package/src/share/agent/index.ts +2 -0
  29. package/src/share/agent/manifest.ts +25 -2
  30. package/src/share/agent/mcp-card.ts +4 -0
  31. package/src/share/agent/openapi.ts +78 -0
  32. package/src/share/agent/search.test.ts +188 -0
  33. package/src/share/agent/search.ts +164 -0
  34. package/src/share/env.d.ts +0 -4
  35. package/src/share/schemas/effects.ts +16 -0
  36. package/src/share/schemas/media.ts +4 -0
  37. package/src/share/schemas/section-shell.ts +2 -1
  38. package/src/share/scripts/external-link-qr.ts +9 -1
  39. package/src/share/scripts/gsap-counter.ts +2 -0
  40. package/src/share/scripts/inline-number-animation.ts +2 -0
  41. package/src/share/scripts/live-photos.ts +2 -0
  42. package/src/share/scripts/video-player.test.ts +210 -0
  43. package/src/share/scripts/video-player.ts +73 -13
  44. package/src/share/semantic/extract.ts +25 -1
  45. package/src/share/semantic/tests/split-sentences.test.ts +57 -0
  46. package/src/share/tests/manifest.test.ts +51 -0
  47. package/src/share/tests/openapi.test.ts +30 -0
  48. package/src/share/types/qrcode-browser.d.ts +18 -0
  49. package/src/share/utility-registry.yaml +2 -2
  50. package/src/integration/crm-buffer.ts +0 -491
  51. package/src/integration-adapter-supabase-crm/adapter.ts +0 -437
  52. package/src/integration-adapter-supabase-crm/client.ts +0 -605
  53. package/src/integration-adapter-supabase-crm/index.ts +0 -28
  54. package/src/integration-adapter-supabase-crm/pipedrive-sync-target.ts +0 -482
  55. package/src/integration-adapter-supabase-crm/supabase-rest.ts +0 -89
  56. package/src/integration-adapter-supabase-crm/tenant-registry.ts +0 -334
  57. package/src/integration-adapter-supabase-crm/tests/change-balance.pbt.test.ts +0 -83
  58. package/src/integration-adapter-supabase-crm/tests/funnel-persistence.test.ts +0 -409
  59. package/src/integration-adapter-supabase-crm/tests/lifecycle-sync.test.ts +0 -288
  60. package/src/integration-adapter-supabase-crm/tests/stage-map.test.ts +0 -80
  61. package/src/integration-adapter-supabase-crm/vitest.config.ts +0 -8
  62. package/src/integration-adapter-supabase-crm/worker.ts +0 -223
  63. 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
- });