ad2app-lib 1.49.0 → 1.59.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.
@@ -6,6 +6,7 @@ import {
6
6
  IsIn,
7
7
  IsInt,
8
8
  IsNotEmpty,
9
+ IsObject,
9
10
  IsOptional,
10
11
  IsString,
11
12
  Matches,
@@ -17,6 +18,8 @@ import {
17
18
  import { I_MediaStage } from './I_Media';
18
19
  import { I_OfferStatus } from './I_Offer';
19
20
  import { AGENCY_GRANT_SCOPES, type I_AgencyGrantScope } from './agency';
21
+ import { PotFields, type I_AgencyPotView, type I_CampaignFile, type I_CreatorPotView, type I_DisclosureCheck } from './agency-pots';
22
+ import { CAMPAIGN_VISIBILITIES, type CampaignVisibility, type I_CampaignListing, type I_TargetProfile } from './dealroom';
20
23
 
21
24
  /**
22
25
  * The campaign loop (spec 175 US7) — an organization runs a campaign with the
@@ -68,9 +71,21 @@ export type SettlementForm = (typeof SETTLEMENT_FORMS)[number];
68
71
 
69
72
  // ── States ───────────────────────────────────────────────────────────────────
70
73
 
74
+ /**
75
+ * An offer campaign negotiates one price per creator (US7); a pot campaign pays
76
+ * from one pot on each creator's own terms (US9). Chosen at creation, fixed once
77
+ * the first creator is invited (FR-030).
78
+ */
79
+ export const AGENCY_CAMPAIGN_KINDS = ['offer', 'pot'] as const;
80
+ export type AgencyCampaignKind = (typeof AGENCY_CAMPAIGN_KINDS)[number];
81
+
71
82
  export const AGENCY_CAMPAIGN_MEMBER_STATES = ['invited', 'joined', 'declined', 'left', 'removed'] as const;
72
83
  export type AgencyCampaignMemberState = (typeof AGENCY_CAMPAIGN_MEMBER_STATES)[number];
73
84
 
85
+ /** How a creator came onto a campaign: 175's invitation, or a Dealroom application to an open listing (spec 148). */
86
+ export const AGENCY_CAMPAIGN_MEMBER_ORIGINS = ['invitation', 'application'] as const;
87
+ export type AgencyCampaignMemberOrigin = (typeof AGENCY_CAMPAIGN_MEMBER_ORIGINS)[number];
88
+
74
89
  /** The offer statuses the agency loop writes. `draft`, `deleted` and `alternativeSuggested` are legacy-only. */
75
90
  export const AGENCY_OFFER_STATUSES = [
76
91
  I_OfferStatus.SENT,
@@ -90,9 +105,13 @@ export type CampaignSide = 'agency' | 'creator';
90
105
  * right now is the server's `availableActions`, never a client guess.
91
106
  *
92
107
  * agency: counter · confirm · reject
93
- * creator: accept · counter · reject
108
+ * creator: accept · counter · reject · apply
109
+ *
110
+ * `apply` (spec 148) is the creator's opening price on an open listing, with
111
+ * no offer yet: it lands in the existing negotiating status, the agency's
112
+ * turn. No offer status is added for it.
94
113
  */
95
- export const AGENCY_OFFER_ACTIONS = ['accept', 'counter', 'confirm', 'reject'] as const;
114
+ export const AGENCY_OFFER_ACTIONS = ['accept', 'counter', 'confirm', 'reject', 'apply'] as const;
96
115
  export type AgencyOfferAction = (typeof AGENCY_OFFER_ACTIONS)[number];
97
116
 
98
117
  /** The agency's acts on the latest material; the creator's only act is to upload. */
@@ -143,6 +162,46 @@ export const AGENCY_CAMPAIGN_EVENT_KINDS = [
143
162
  'material_rejected',
144
163
  'published',
145
164
  'milestone_confirmed',
165
+ // US9: the pot. Every money act carries its amount (FR-021, FR-033).
166
+ 'pot_applied',
167
+ 'pot_application_withdrawn',
168
+ 'pot_accepted',
169
+ 'pot_declined',
170
+ 'pot_released',
171
+ 'pot_refilled',
172
+ 'pot_moved',
173
+ 'pot_deadline_moved',
174
+ 'pot_publish_time_set',
175
+ 'pot_publish_time_confirmed',
176
+ /** Zernio refused the post at scheduling; `note` carries its reason. */
177
+ 'pot_publish_refused',
178
+ 'pot_published',
179
+ 'pot_counted',
180
+ 'pot_frozen',
181
+ 'pot_bonus_held',
182
+ 'pot_bonus_approved',
183
+ 'pot_bonus_capped',
184
+ 'pot_bonus_message',
185
+ 'pot_kept',
186
+ 'pot_broken',
187
+ 'pot_removal_decided',
188
+ 'caption_edited',
189
+ 'file_uploaded',
190
+ 'file_removed',
191
+ // Spec 148 money release (DORMANT): the contract, money and payout steps of a
192
+ // Dealroom deal are kinds in this one log (FR-008a). Mirrored by
193
+ // DEALROOM_MONEY_EVENT_KINDS in dealroom.ts.
194
+ 'contract_sent',
195
+ 'contract_signed',
196
+ 'contract_declined',
197
+ 'deposit_paid',
198
+ 'deal_funded',
199
+ 'deal_closed_unviewed',
200
+ 'deal_refund_requested',
201
+ 'deal_refunded',
202
+ 'guarantee_claimed',
203
+ 'payout_initiated',
204
+ 'payout_paid',
146
205
  ] as const;
147
206
  export type AgencyCampaignEventKind = (typeof AGENCY_CAMPAIGN_EVENT_KINDS)[number];
148
207
 
@@ -214,6 +273,10 @@ export interface I_AgencyMaterial {
214
273
  uploadedAt: string;
215
274
  /** The note sent with a change request or a rejection of THIS version. */
216
275
  note: string | null;
276
+ /** The post's caption, uploaded with the material (US9, FR-032); the creator may edit it until publication. Optional: 1.49 backends omit it. */
277
+ caption?: string | null;
278
+ /** `checkDisclosure(caption)`, returned with every read (FR-040); null without a caption. Optional: 1.49 backends omit it. */
279
+ disclosure?: I_DisclosureCheck | null;
217
280
  }
218
281
 
219
282
  export interface I_AgencyMilestone {
@@ -233,9 +296,28 @@ export interface I_AgencyMilestone {
233
296
  export interface I_AgencyCampaignEvent {
234
297
  kind: AgencyCampaignEventKind;
235
298
  side: CampaignSide;
299
+ /** Done by ad2app itself (the daily pot job), not by a person on that side. */
300
+ byAd2app?: true;
236
301
  at: string;
237
302
  price?: I_Money;
303
+ /** A pot act's amount: a refill, a move, a reserved guarantee, an accrued bonus. */
304
+ amount?: I_Money;
238
305
  note?: string;
306
+ /** Set when a delegate acted on the creator's side through a `deals` grant (spec 148 FR-017a). */
307
+ actingGrantId?: string;
308
+ /** With `actingGrantId`: the member who acted, shown as "<member> for <creator>". */
309
+ actingMemberName?: string;
310
+ /** The act came from the creator's Dealroom application (spec 148 US4), not an invitation. */
311
+ fromApplication?: true;
312
+ }
313
+
314
+ /** Spec 148 US8 AC3: one act a manager did in the creator's deal, for the creator's own view. */
315
+ export interface I_CreatorDelegateAct {
316
+ kind: AgencyCampaignEventKind;
317
+ at: string;
318
+ /** The member of the managing agency who acted. */
319
+ memberName: string;
320
+ organizationName: string;
239
321
  }
240
322
 
241
323
  export interface I_CampaignCreatorIdentity {
@@ -250,6 +332,8 @@ export interface I_AgencyCampaignMemberRow {
250
332
  memberId: string;
251
333
  creator: I_CampaignCreatorIdentity;
252
334
  state: AgencyCampaignMemberState;
335
+ /** Invitation or Dealroom application (spec 148). Absent on older backends: read as 'invitation'. */
336
+ origin?: AgencyCampaignMemberOrigin;
253
337
  /** The board column, or the reason the row is off the board. */
254
338
  step: AgencyCampaignStep | AgencyCampaignOffBoard;
255
339
  /** When the row reached its current step. */
@@ -279,6 +363,8 @@ export interface I_AgencyCampaignPipeline {
279
363
  export interface I_AgencyCampaignCard {
280
364
  id: string;
281
365
  name: string;
366
+ /** Absent from 1.49 backends: read as 'offer'. */
367
+ kind?: AgencyCampaignKind;
282
368
  isDraft: boolean;
283
369
  liveByAt: string | null;
284
370
  draftDueAt: string | null;
@@ -291,12 +377,18 @@ export interface I_AgencyCampaignCard {
291
377
  export interface I_AgencyCampaign {
292
378
  id: string;
293
379
  name: string;
380
+ /** Absent from 1.49 backends: read as 'offer'. */
381
+ kind?: AgencyCampaignKind;
382
+ /** Set on a pot campaign; the pot board itself is `GET /agency/campaigns/:id/pot/board`. */
383
+ pot?: I_AgencyPotView | null;
294
384
  isDraft: boolean;
295
385
  brief: I_AgencyCampaignBrief;
296
386
  members: I_AgencyCampaignMemberRow[];
297
387
  milestones: I_AgencyMilestone[];
298
388
  pipeline: I_AgencyCampaignPipeline;
299
389
  createdAt: string;
390
+ /** Spec 148: the Dealroom listing fields and their derived state. Absent on older backends. */
391
+ listing?: I_CampaignListing;
300
392
  }
301
393
 
302
394
  /** Opening one creator (FR-024): their course with dates, notes, files and milestones. */
@@ -306,6 +398,31 @@ export interface I_AgencyCampaignCreatorDetail {
306
398
  events: I_AgencyCampaignEvent[];
307
399
  materials: I_AgencyMaterial[];
308
400
  milestones: I_AgencyMilestone[];
401
+ /** Spec 148 T067: an offer deal's agreed publication time. Absent on a pot (its entry carries it) and on older backends. */
402
+ publication?: I_OfferPublication | null;
403
+ }
404
+
405
+ /**
406
+ * Spec 148 T067: an OFFER deal publishes through 175 US9's auto-publish path.
407
+ * The organization sets the agreed time on a confirmed offer with an approved
408
+ * material (`PATCH /agency/campaigns/:id/members/:memberId/publish-at`); the
409
+ * creator confirms that exact time once (`POST /agency/campaign-invitations/
410
+ * :memberId/publish-at/confirm`, the same route as a pot's); the post then goes
411
+ * out from the creator's own account, and publication is known by construction.
412
+ * `POST .../members/:memberId/published` stays for off-platform posting.
413
+ */
414
+ export interface I_OfferPublication {
415
+ publishAt: string | null;
416
+ publishConfirmedAt: string | null;
417
+ /** The post is scheduled on the creator's account (the confirmed time is within the signed-link horizon). */
418
+ scheduled: boolean;
419
+ publishedAt: string | null;
420
+ /** The latest refusal at scheduling since the time was set or confirmed; the creator confirms again. */
421
+ publishRefusal: { at: string; reason: string } | null;
422
+ /** Organization side: the offer is confirmed, the latest material approved, and nothing is published yet. */
423
+ canSetPublishAt: boolean;
424
+ /** Creator side: a time is set and not confirmed, and the latest material's caption passes the disclosure check. */
425
+ canConfirmPublishAt: boolean;
309
426
  }
310
427
 
311
428
  /** `GET /agency/campaigns/attention` — what waits on the agency (FR-022, the rail). */
@@ -313,6 +430,8 @@ export interface I_AgencyCampaignAttention {
313
430
  counterOffers: number;
314
431
  materialsToReview: number;
315
432
  milestonesReady: number;
433
+ /** US9: applications to answer, held bonuses to decide, and posts taken down after the bonus window. */
434
+ potDecisions?: number;
316
435
  total: number;
317
436
  }
318
437
 
@@ -348,7 +467,12 @@ export interface I_CreatorCampaignView {
348
467
  memberId: string;
349
468
  state: AgencyCampaignMemberState;
350
469
  organization: { id: string; name: string };
351
- campaign: { id: string; name: string; brief: I_AgencyCampaignBrief };
470
+ /** `kind` is absent from 1.49 backends: read as 'offer'. */
471
+ campaign: { id: string; name: string; brief: I_AgencyCampaignBrief; kind?: AgencyCampaignKind };
472
+ /** Set on a pot campaign: the creator's own terms, earnings and the two-valued availability, nothing pot-level (SC-016). */
473
+ pot?: I_CreatorPotView | null;
474
+ /** The agency's files for the campaign (FR-039): every one is for its creators; opened through a signed read. */
475
+ files?: I_CampaignFile[];
352
476
  creatorBrief: string | null;
353
477
  /** The access request folded into the invitation, while it is open. */
354
478
  requestedScopes: I_AgencyGrantScope[];
@@ -360,6 +484,10 @@ export interface I_CreatorCampaignView {
360
484
  canLeave: boolean;
361
485
  milestones: I_AgencyMilestone[];
362
486
  invitedAt: string;
487
+ /** Spec 148 T067: an offer deal's agreed publication time; null on a pot (see `pot.entry`) and before a confirmed offer. */
488
+ publication?: I_OfferPublication | null;
489
+ /** Spec 148 US8 AC3: what a manager did in this deal for the creator, oldest first. Absent on older backends. */
490
+ delegateActs?: I_CreatorDelegateAct[];
363
491
  }
364
492
 
365
493
  /** `GET /agency/campaign-invitations/mine` — pending invitations and live campaigns. */
@@ -390,8 +518,8 @@ export interface I_SignedRead {
390
518
  // never reads. `deliverables` is an array of plain objects checked item by
391
519
  // item in the service against CAMPAIGN_PLACEMENTS — the pair is the rule.
392
520
 
393
- /** The brief's fields, shared by create and update. All optional on the wire. */
394
- class AgencyCampaignBriefFields {
521
+ /** The brief's fields, shared by create and update. All optional on the wire. The pot's fields ride along (US9). */
522
+ class AgencyCampaignBriefFields extends PotFields {
395
523
  @IsOptional()
396
524
  @IsString()
397
525
  @MaxLength(10000)
@@ -428,7 +556,42 @@ class AgencyCampaignBriefFields {
428
556
  @Max(20)
429
557
  revisionRounds?: number | null;
430
558
 
559
+ // ── Spec 148: the open listing. Refused by the backend while DEALROOM_ENABLED is off. ──
560
+
561
+ /** `open` lists the campaign in the Dealroom feed; it needs a target profile and the organization's logo. */
562
+ @IsOptional()
563
+ @IsIn(CAMPAIGN_VISIBILITIES as unknown as string[])
564
+ visibility?: CampaignVisibility;
565
+
566
+ /** Checked field by field in the service (flat bodies, see above). Blank constraint = no filter. */
567
+ @IsOptional()
568
+ @IsObject()
569
+ targetProfile?: I_TargetProfile | null;
570
+
571
+ /** Null = unlimited. Once frozen, it may only grow. */
572
+ @IsOptional()
573
+ @IsInt()
574
+ @Min(1)
575
+ @Max(10000)
576
+ slots?: number | null;
577
+
578
+ /** Offer kind only (a pot keeps its own). Once frozen, it may only move later. */
579
+ @IsOptional()
580
+ @IsDateString()
581
+ applicationDeadline?: string | null;
582
+
583
+ /** Any date closes the listing NOW (server time): new applications stop, running deals do not. */
584
+ @IsOptional()
585
+ @IsDateString()
586
+ closedAt?: string | null;
587
+
431
588
  protected assignBrief(data?: Partial<AgencyCampaignBriefFields>): void {
589
+ this.assignPot(data);
590
+ this.visibility = data?.visibility;
591
+ this.targetProfile = data?.targetProfile;
592
+ this.slots = data?.slots;
593
+ this.applicationDeadline = data?.applicationDeadline;
594
+ this.closedAt = data?.closedAt;
432
595
  this.text = data?.text;
433
596
  this.deliverables = data?.deliverables;
434
597
  this.draftDueAt = data?.draftDueAt;
@@ -445,9 +608,15 @@ export class CreateAgencyCampaignDto extends AgencyCampaignBriefFields {
445
608
  @MaxLength(128)
446
609
  name: string;
447
610
 
611
+ /** Absent = an offer campaign (US7). */
612
+ @IsOptional()
613
+ @IsIn(AGENCY_CAMPAIGN_KINDS as unknown as string[])
614
+ kind?: AgencyCampaignKind;
615
+
448
616
  constructor(data?: Partial<CreateAgencyCampaignDto>) {
449
617
  super();
450
618
  this.name = data?.name ?? '';
619
+ this.kind = data?.kind;
451
620
  this.assignBrief(data);
452
621
  }
453
622
  }
@@ -464,10 +633,16 @@ export class UpdateAgencyCampaignDto extends AgencyCampaignBriefFields {
464
633
  @IsBoolean()
465
634
  isDraft?: boolean;
466
635
 
636
+ /** Switch offer ↔ pot; refused once the first creator is invited (FR-030). */
637
+ @IsOptional()
638
+ @IsIn(AGENCY_CAMPAIGN_KINDS as unknown as string[])
639
+ kind?: AgencyCampaignKind;
640
+
467
641
  constructor(data?: Partial<UpdateAgencyCampaignDto>) {
468
642
  super();
469
643
  this.name = data?.name;
470
644
  this.isDraft = data?.isDraft;
645
+ this.kind = data?.kind;
471
646
  this.assignBrief(data);
472
647
  }
473
648
  }
@@ -608,8 +783,15 @@ export class ConfirmMaterialUploadDto {
608
783
  @MaxLength(512)
609
784
  uploadId: string;
610
785
 
786
+ /** The post's caption, sent with the material on a pot campaign (FR-032). */
787
+ @IsOptional()
788
+ @IsString()
789
+ @MaxLength(2200)
790
+ caption?: string;
791
+
611
792
  constructor(data?: Partial<ConfirmMaterialUploadDto>) {
612
793
  this.uploadId = data?.uploadId ?? '';
794
+ this.caption = data?.caption;
613
795
  }
614
796
  }
615
797
 
@@ -0,0 +1,255 @@
1
+ import { test } from "node:test";
2
+ import { validateSync } from "class-validator";
3
+ import assert from "node:assert/strict";
4
+
5
+ import {
6
+ ApplyToPotDto,
7
+ POT_CURRENCIES,
8
+ CREATOR_POT_FORBIDDEN_KEY,
9
+ DISCLOSURE_VISIBLE_CHARS,
10
+ POT_STEPS,
11
+ checkDisclosure,
12
+ creatorPotForbiddenKeys,
13
+ POT_REMOVAL_DECISIONS,
14
+ potBonusMinor,
15
+ potEarnings,
16
+ potViewsAtMax,
17
+ potCostPer1000Minor,
18
+ type I_CreatorPotView,
19
+ } from "./agency-pots";
20
+ import { AGENCY_CAMPAIGN_EVENT_KINDS, CreateAgencyCampaignDto } from "./agency-campaigns";
21
+ import { EVENTS } from "../analytics";
22
+
23
+ // ── checkDisclosure (FR-040) ────────────────────────────────────────────────
24
+
25
+ test("every UOKiK wording passes, with or without #, in any case", () => {
26
+ const passing = [
27
+ "reklama",
28
+ "#reklama Poranny bieg",
29
+ "REKLAMA: nowa kolekcja",
30
+ "materiał reklamowy",
31
+ "#materiałreklamowy",
32
+ "Materiał reklamowy marki X",
33
+ "współpraca reklamowa z @northlight",
34
+ "#współpracareklamowa",
35
+ "płatna współpraca",
36
+ "#płatnawspółpraca",
37
+ "post sponsorowany",
38
+ "#postsponsorowany",
39
+ // Without Polish diacritics, as people type on a phone.
40
+ "material reklamowy",
41
+ "wspolpraca reklamowa",
42
+ "platna wspolpraca",
43
+ ];
44
+ for (const caption of passing) {
45
+ assert.equal(checkDisclosure(caption).result, "pass", caption);
46
+ }
47
+ });
48
+
49
+ test("the four bare tags are weak, never accepted silently", () => {
50
+ for (const tag of ["#współpraca", "#ad", "#spons", "#promo", "#wspolpraca", "#AD"]) {
51
+ const check = checkDisclosure(`Nowa kolekcja jest super ${tag}`);
52
+ assert.equal(check.result, "weak", tag);
53
+ assert.equal(check.found, tag);
54
+ }
55
+ });
56
+
57
+ test("a caption with no disclosure at all is missing", () => {
58
+ assert.deepEqual(checkDisclosure("Poranny bieg w nowej kurtce"), { result: "missing", found: null, at: null });
59
+ assert.equal(checkDisclosure("").result, "missing");
60
+ });
61
+
62
+ test("a wording past the first 125 characters is not visible and does not pass", () => {
63
+ assert.equal(DISCLOSURE_VISIBLE_CHARS, 125, "FR-040: visible without expanding the caption");
64
+ const filler = "x".repeat(DISCLOSURE_VISIBLE_CHARS);
65
+ const check = checkDisclosure(`${filler} #reklama`);
66
+ assert.equal(check.result, "weak");
67
+ assert.equal(check.found, "#reklama");
68
+ assert.equal(check.at, DISCLOSURE_VISIBLE_CHARS + 1);
69
+ // The last visible position still passes.
70
+ const edge = `${"x".repeat(DISCLOSURE_VISIBLE_CHARS - "reklama".length)}reklama`;
71
+ assert.equal(edge.length, DISCLOSURE_VISIBLE_CHARS);
72
+ assert.equal(checkDisclosure(edge).result, "missing", "glued to a word is not the word");
73
+ assert.equal(checkDisclosure(`${"x ".repeat(59)}reklama`).result, "pass");
74
+ });
75
+
76
+ test("a wording inside another word is not a disclosure", () => {
77
+ for (const caption of ["reklamacja przyjęta", "antyreklama", "#reklamacja", "#adidas", "#promocja", "#sponsor"]) {
78
+ assert.notEqual(checkDisclosure(caption).result, "pass", caption);
79
+ }
80
+ assert.equal(checkDisclosure("#adidas").result, "missing");
81
+ });
82
+
83
+ test("a strong wording wins over a weak tag anywhere in the caption", () => {
84
+ assert.equal(checkDisclosure("#współpraca #reklama nowa kurtka").result, "pass");
85
+ });
86
+
87
+ // ── potEarnings (FR-030, FR-032, FR-035) ───────────────────────────────────
88
+
89
+ test("earnings are the guarantee plus the rate times views, exact to the minor unit", () => {
90
+ // 15 000,00 zł + 70,00 zł per 1000 × 1 000 views = 15 070,00 zł
91
+ assert.equal(potEarnings({ guaranteeMinor: 1_500_000, ratePer1000Minor: 7_000, views: 1_000, maxMinor: 9_000_000 }), 1_507_000);
92
+ // 55,00 zł per 1000 × 845 300 views = 46 491,50 zł; plus 20 000,00 zł
93
+ assert.equal(potEarnings({ guaranteeMinor: 2_000_000, ratePer1000Minor: 5_500, views: 845_300, maxMinor: 9_000_000 }), 6_649_150);
94
+ // A fraction of a grosz is never paid: rounded down.
95
+ assert.equal(potBonusMinor(3_333, 1), 3);
96
+ assert.equal(potBonusMinor(1, 999), 0);
97
+ });
98
+
99
+ test("earnings never exceed the maximum", () => {
100
+ assert.equal(potEarnings({ guaranteeMinor: 1_500_000, ratePer1000Minor: 7_000, views: 1_420_000, maxMinor: 9_000_000 }), 9_000_000);
101
+ for (let i = 0; i < 2_000; i++) {
102
+ const maxMinor = 1 + Math.floor(Math.random() * 20_000_000);
103
+ const guaranteeMinor = Math.floor(Math.random() * maxMinor);
104
+ const ratePer1000Minor = Math.floor(Math.random() * 100_000);
105
+ const views = Math.floor(Math.random() * 50_000_000);
106
+ const earned = potEarnings({ guaranteeMinor, ratePer1000Minor, views, maxMinor });
107
+ assert.ok(Number.isInteger(earned));
108
+ assert.ok(earned <= maxMinor, `${earned} > ${maxMinor}`);
109
+ assert.ok(earned >= guaranteeMinor);
110
+ }
111
+ });
112
+
113
+ test("views at the maximum are the exact count, rounded up", () => {
114
+ // (90 000 − 15 000) / 70 × 1000 = 1 071 428.57… → 1 071 429
115
+ const terms = { guaranteeMinor: 1_500_000, ratePer1000Minor: 7_000, maxMinor: 9_000_000 };
116
+ const atMax = potViewsAtMax(terms);
117
+ assert.equal(atMax, 1_071_429);
118
+ assert.equal(potEarnings({ ...terms, views: atMax }), 9_000_000);
119
+ assert.ok(potEarnings({ ...terms, views: atMax - 1 }) < 9_000_000);
120
+ assert.equal(potViewsAtMax({ ...terms, ratePer1000Minor: 0 }), null, "a zero rate never reaches the maximum");
121
+ });
122
+
123
+ // ── The creator's view carries nothing pot-level (SC-016) ───────────────────
124
+
125
+ test("I_CreatorPotView has no key matching a pot-level word", () => {
126
+ // A full sample of the type: every key the creator receives. Adding a key to
127
+ // the interface without adding it here fails to compile (the satisfies check).
128
+ const sample = {
129
+ campaignId: "c",
130
+ currency: "PLN",
131
+ maxPerCreatorMinor: 1,
132
+ bonusWindowDays: 30,
133
+ keepUpMonths: 12,
134
+ coverUrl: null,
135
+ applicationDeadlineAt: null,
136
+ availability: "full_max_available",
137
+ approx: { currency: "EUR", maxPerCreatorMinor: 1, rateDate: "2026-09-30" },
138
+ schedule: { materialsDueAt: null, publicationDueAt: null, bonusEndsAt: null, keepUpEndsAt: null },
139
+ publishing: { paidPartnershipLabel: true, sponsorHandle: null, collabHandle: null, firstComment: null, commentsEnabled: true },
140
+ entry: {
141
+ state: "accepted",
142
+ terms: { guaranteeMinor: 1, ratePer1000Minor: 1 },
143
+ appliedAt: "x",
144
+ decidedAt: null,
145
+ publishAt: null,
146
+ publishConfirmedAt: null,
147
+ publishedAt: null,
148
+ postUrl: null,
149
+ bonusEndsAt: null,
150
+ keepUpEndsAt: null,
151
+ outcome: null,
152
+ mayTakeDown: false,
153
+ earnings: { guaranteeMinor: 1, bonusMinor: 0, heldBonusMinor: 0, totalMinor: 1, views: 0, lastCountAt: null, frozen: false },
154
+ },
155
+ canApply: false,
156
+ canWithdraw: false,
157
+ canConfirmPublishAt: false,
158
+ medianViews: null,
159
+ } satisfies Required<I_CreatorPotView>;
160
+ assert.deepEqual(creatorPotForbiddenKeys(sample), []);
161
+ });
162
+
163
+ test("the fence catches a pot-level key at any depth", () => {
164
+ assert.deepEqual(creatorPotForbiddenKeys({ a: { b: [{ potBudgetMinor: 1 }] } }), ["a.b.0.potBudgetMinor"]);
165
+ for (const key of ["budgetMinor", "remainderMinor", "reservedMinor", "fundedMinor", "max_budget", "maxBudgetMinor", "goalViews", "unreserved", "creatorsCoveredAtFullMax", "maxCommitmentMinor", "acceptedCreators"]) {
166
+ assert.ok(CREATOR_POT_FORBIDDEN_KEY.test(key), key);
167
+ }
168
+ // SC-016 names the earned amount; views and the blended cost are pot-level too (review 2026-10-06 M9).
169
+ for (const key of ["earnedMinor", "blendedCostPer1000Minor", "resultViews", "heldViews", "countedViews"]) {
170
+ assert.ok(CREATOR_POT_FORBIDDEN_KEY.test(key), key);
171
+ }
172
+ for (const key of ["maxPerCreatorMinor", "guaranteeMinor", "keepUpMonths", "availability", "acceptedAt", "decidedAt", "earnings", "bonusMinor", "totalMinor", "views", "medianViews"]) {
173
+ assert.ok(!CREATOR_POT_FORBIDDEN_KEY.test(key), key);
174
+ }
175
+ });
176
+
177
+ test("a decomposed Polish letter, a double space or a no-break space still pass (review 2026-10-06 L9)", () => {
178
+ assert.equal(checkDisclosure("współpraca reklamowa".normalize("NFD")).result, "pass");
179
+ assert.equal(checkDisclosure("materiał reklamowy").result, "pass");
180
+ assert.equal(checkDisclosure("płatna\u00a0współpraca").result, "pass");
181
+ });
182
+
183
+ test("a tag glued to the word before it starts at its #, so the fold is judged from there", () => {
184
+ const caption = "x".repeat(DISCLOSURE_VISIBLE_CHARS - 2) + "x#reklama";
185
+ const check = checkDisclosure(caption);
186
+ assert.equal(check.at, DISCLOSURE_VISIBLE_CHARS - 1);
187
+ assert.equal(check.result, "pass");
188
+ assert.equal(check.found, "#reklama");
189
+ });
190
+
191
+ test("the money helpers never throw on a non-whole input", () => {
192
+ assert.equal(potBonusMinor(Number.NaN, 1000), 0);
193
+ assert.equal(potBonusMinor(100, Number.POSITIVE_INFINITY), 0);
194
+ assert.equal(potViewsAtMax({ guaranteeMinor: 0, ratePer1000Minor: 0.5, maxMinor: 100 }), null);
195
+ assert.equal(potCostPer1000Minor(10.5, 1000), 10);
196
+ assert.equal(potCostPer1000Minor(100, Number.NaN), null);
197
+ });
198
+
199
+ // ── States, events, DTOs ─────────────────────────────────────────────────────
200
+
201
+ test("the pot board columns are the approved design's, in order", () => {
202
+ assert.deepEqual([...POT_STEPS], ["invited", "applied", "accepted", "material_in", "scheduled", "counting", "final", "done"]);
203
+ });
204
+
205
+ test("every pot act is an event kind, recorded with actor and amount (FR-021, FR-033)", () => {
206
+ for (const kind of [
207
+ "pot_applied",
208
+ "pot_application_withdrawn",
209
+ "pot_accepted",
210
+ "pot_declined",
211
+ "pot_released",
212
+ "pot_refilled",
213
+ "pot_moved",
214
+ "pot_deadline_moved",
215
+ "pot_publish_time_set",
216
+ "pot_publish_time_confirmed",
217
+ "pot_publish_refused",
218
+ "pot_published",
219
+ "pot_counted",
220
+ "pot_frozen",
221
+ "pot_bonus_held",
222
+ "pot_bonus_approved",
223
+ "pot_bonus_capped",
224
+ "pot_bonus_message",
225
+ "pot_kept",
226
+ "pot_broken",
227
+ "caption_edited",
228
+ "file_uploaded",
229
+ "file_removed",
230
+ ]) {
231
+ assert.ok((AGENCY_CAMPAIGN_EVENT_KINDS as readonly string[]).includes(kind), kind);
232
+ }
233
+ });
234
+
235
+ test("the pot analytics events are snake_case and carry the agency prefix", () => {
236
+ const pot = Object.entries(EVENTS).filter(([name]) => name.startsWith("AGENCY_POT_"));
237
+ assert.ok(pot.length >= 6);
238
+ for (const [, value] of pot) assert.match(value, /^agency_pot_[a-z_]+$/);
239
+ });
240
+
241
+ test("an application constructs empty, as the validation pipe does", () => {
242
+ assert.doesNotThrow(() => new ApplyToPotDto());
243
+ assert.equal(new ApplyToPotDto({ guaranteeMinor: 5, ratePer1000Minor: 7 }).ratePer1000Minor, 7);
244
+ });
245
+
246
+ test("the agency's decision on a removal after the bonus window is keep or withhold, nothing else", () => {
247
+ assert.deepEqual([...POT_REMOVAL_DECISIONS], ["keep_base", "withhold_base"]);
248
+ });
249
+
250
+ test("a pot is kept in a two-decimal currency only", () => {
251
+ const errors = (potCurrency: string) =>
252
+ validateSync(new CreateAgencyCampaignDto({ name: "x", kind: "pot", potCurrency })).filter((e) => e.property === "potCurrency");
253
+ for (const c of POT_CURRENCIES) assert.deepEqual(errors(c), [], c);
254
+ for (const c of ["JPY", "KWD", "pln", "XXX"]) assert.equal(errors(c).length, 1, c);
255
+ });