From 343fea7f3cf577bc6e2a67450fd3bc5561ea6512 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:10:43 +0200 Subject: [PATCH] Setup draft tag --- apps/api/src/app/account/account.service.ts | 9 +- .../src/app/activities/activities.service.ts | 87 ++++-- apps/api/src/app/import/import.service.ts | 20 +- .../src/app/portfolio/portfolio.service.ts | 8 +- apps/api/src/helper/activity.helper.ts | 23 ++ apps/api/src/services/tag/tag.service.ts | 6 +- .../holding-detail-dialog.component.ts | 21 +- docs/migrate-is-draft-to-draft-tag.md | 287 ------------------ 8 files changed, 125 insertions(+), 336 deletions(-) delete mode 100644 docs/migrate-is-draft-to-draft-tag.md diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index c7cb51fa4..ccd7d24bf 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -89,7 +89,10 @@ export class AccountService { orderBy?: Prisma.AccountOrderByWithRelationInput; }): Promise< (AccountWithBalance & { - activities?: (Order & { SymbolProfile?: SymbolProfile; tags?: Tag[] })[]; + activities?: (Order & { + SymbolProfile?: SymbolProfile; + tags?: Pick[]; + })[]; balances?: AccountBalance[]; platform?: Platform; tags?: Tag[]; @@ -172,7 +175,7 @@ export class AccountService { tagIds?: string[]; userId: string; }): Promise { - await this.tagService.validateTagIdsForAccount({ tagIds, userId }); + await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId }); const account = await this.prismaService.account.create({ data: { @@ -312,7 +315,7 @@ export class AccountService { userId: string; where: Prisma.AccountWhereUniqueInput; }): Promise { - await this.tagService.validateTagIdsForAccount({ tagIds, userId }); + await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId }); const account = await this.prismaService.account.update({ data: { diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 19c57d155..807ab255d 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -8,6 +8,7 @@ import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import { + getTagsWithDraftTag, isActivityInFuture, isDraftTagToBeAssigned, WHERE_ACTIVITY_NOT_DRAFT @@ -119,7 +120,7 @@ export class ActivitiesService { tags, userId }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { - await this.tagService.validateTagIds({ + await this.tagService.validateTagIdsWithoutDraftTag({ userId, tagIds: tags.map(({ id }) => { return id; @@ -127,6 +128,7 @@ export class ActivitiesService { }); const activities = await this.prismaService.order.findMany({ + include: { tags: { select: { id: true } } }, where: { userId, SymbolProfile: { @@ -136,20 +138,31 @@ export class ActivitiesService { } }); + const tagsToAssign = tags.map(({ id }) => { + return { id }; + }); + await Promise.all( - activities.map(({ id }) => - this.prismaService.order.update({ + activities.map((activity) => { + // The set operation replaces all existing connections with the provided + // ones, hence the "Draft" tag of an individual activity is carried over + const isDraft = isDraftActivity(activity); + + const tagsToSet = isDraft + ? [...tagsToAssign, { id: TAG_ID_DRAFT }] + : tagsToAssign; + + return this.prismaService.order.update({ data: { + // @deprecated Mirrors the "Draft" tag until the attribute is removed + isDraft, tags: { - // The set operation replaces all existing connections with the provided ones - set: tags.map((tag) => { - return { id: tag.id }; - }) + set: tagsToSet } }, - where: { id } - }) - ) + where: { id: activity.id } + }); + }) ); this.eventEmitter.emit( @@ -268,12 +281,12 @@ export class ActivitiesService { const orderData: Prisma.OrderCreateInput = data; - const tagsToConnect = isDraftTagToBeAssigned({ + const tagsToConnect = getTagsWithDraftTag({ + tags, date: data.date as Date, + draftTag: { id: TAG_ID_DRAFT }, type: data.type - }) - ? uniqBy([...tags, { id: TAG_ID_DRAFT }], 'id') - : tags; + }); const activity = await this.prismaService.order.create({ data: { @@ -648,7 +661,11 @@ export class ActivitiesService { }; } - if (includeDrafts === false) { + const isFilteredByDraftTag = filtersByTag.some(({ id }) => { + return id === TAG_ID_DRAFT; + }); + + if (includeDrafts === false && !isFilteredByDraftTag) { andConditions.push(WHERE_ACTIVITY_NOT_DRAFT); } @@ -979,6 +996,7 @@ export class ActivitiesService { userId: string; where: Prisma.OrderWhereUniqueInput; }): Promise { + const areTagsProvided = data.tags !== undefined; const tags = data.tags ?? []; await this.tagService.validateTagIds({ @@ -1024,23 +1042,40 @@ export class ActivitiesService { delete data.symbol; delete data.tags; - const tagsToSet = isDraftTagToBeAssigned({ - storedDate, - date: data.date as Date, - type: data.type - }) - ? uniqBy([...tags, { id: TAG_ID_DRAFT }], 'id') - : tags; + // Leave the tags untouched if the request does not provide them, so that a + // partial update cannot drop the "Draft" tag + let isDraft: boolean; + let tagsToUpdate: Prisma.OrderUpdateInput['tags']; + + if (areTagsProvided) { + const tagsToSet = getTagsWithDraftTag({ + storedDate, + tags, + date: data.date as Date, + draftTag: { id: TAG_ID_DRAFT }, + type: data.type + }); + + isDraft = isDraftActivity({ tags: tagsToSet }); + tagsToUpdate = { set: tagsToSet }; + } else if ( + isDraftTagToBeAssigned({ + storedDate, + date: data.date as Date, + type: data.type + }) + ) { + isDraft = true; + tagsToUpdate = { connect: { id: TAG_ID_DRAFT } }; + } const activity = await this.prismaService.order.update({ where, data: { ...data, // @deprecated Mirrors the "Draft" tag until the attribute is removed - isDraft: isDraftActivity({ tags: tagsToSet }), - tags: { - set: tagsToSet - } + isDraft, + tags: tagsToUpdate } }); diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 7d039169b..5875afba0 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -2,7 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { isDraftTagToBeAssigned } from '@ghostfolio/api/helper/activity.helper'; +import { getTagsWithDraftTag } from '@ghostfolio/api/helper/activity.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -716,6 +716,11 @@ export class ImportService { }); } + // Preview the "Draft" tag which createActivity() assigns in a real run + const draftTag = tags.find(({ id }) => { + return id === TAG_ID_DRAFT; + }) ?? { id: TAG_ID_DRAFT, name: 'DRAFT' }; + const activities: Activity[] = []; for (const activity of activitiesExtendedWithErrors) { @@ -778,16 +783,13 @@ export class ImportService { }); if (isDryRun) { - // Preview the "Draft" tag which createActivity() assigns in a real run - const draftTag = tags.find(({ id: tagId }) => { - return tagId === TAG_ID_DRAFT; + const previewTags = getTagsWithDraftTag({ + date, + draftTag, + type, + tags: validatedTags }); - const previewTags = - draftTag && isDraftTagToBeAssigned({ date, type }) - ? uniqBy([...validatedTags, draftTag], 'id') - : validatedTags; - order = { comment, currency, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 390280fd1..7ef01db08 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -34,6 +34,7 @@ import { import { DEFAULT_CURRENCY, DEFAULT_DATE_RANGE, + TAG_ID_DRAFT, TAG_ID_EMERGENCY_FUND, TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY @@ -175,7 +176,12 @@ export class PortfolioService { this.accountService.accounts({ where, include: { - activities: { include: { SymbolProfile: true, tags: true } }, + activities: { + include: { + SymbolProfile: true, + tags: { select: { id: true }, where: { id: TAG_ID_DRAFT } } + } + }, platform: true, tags: true }, diff --git a/apps/api/src/helper/activity.helper.ts b/apps/api/src/helper/activity.helper.ts index 879ae7d53..f0147c38d 100644 --- a/apps/api/src/helper/activity.helper.ts +++ b/apps/api/src/helper/activity.helper.ts @@ -5,6 +5,7 @@ import { import { Prisma, Type as ActivityType } from '@prisma/client'; import { endOfToday, isAfter } from 'date-fns'; +import { uniqBy } from 'lodash'; export const WHERE_ACTIVITY_NOT_DRAFT: Prisma.OrderWhereInput = { tags: { @@ -14,6 +15,28 @@ export const WHERE_ACTIVITY_NOT_DRAFT: Prisma.OrderWhereInput = { } }; +export function getTagsWithDraftTag({ + date, + draftTag, + endOfTodayDate = endOfToday(), + storedDate, + tags, + type +}: { + date: Date; + draftTag: T; + endOfTodayDate?: Date; + storedDate?: Date; + tags: T[]; + type: ActivityType; +}) { + if (!isDraftTagToBeAssigned({ date, endOfTodayDate, storedDate, type })) { + return tags; + } + + return uniqBy([...tags, draftTag], 'id'); +} + export function isActivityInFuture({ date, endOfTodayDate = endOfToday() diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index 7680e5f3d..abcf349d9 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -162,15 +162,15 @@ export class TagService { } } - public async validateTagIdsForAccount({ + public async validateTagIdsWithoutDraftTag({ tagIds, userId }: { tagIds: string[]; userId: string; }) { - // The "DRAFT" tag qualifies an individual activity and cannot be assigned - // to an account + // The "DRAFT" tag qualifies an individual activity and can therefore + // neither be assigned to an account nor to all activities of a holding if (tagIds?.includes(TAG_ID_DRAFT)) { throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 57b8196d6..28e713a41 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -3,7 +3,8 @@ import { DEFAULT_PAGE_SIZE, E_MAIL_LINE_BREAK, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, - NUMERICAL_PRECISION_THRESHOLD_4_FIGURES + NUMERICAL_PRECISION_THRESHOLD_4_FIGURES, + TAG_ID_DRAFT } from '@ghostfolio/common/config'; import { CreateOrderDto } from '@ghostfolio/common/dtos'; import { @@ -591,12 +592,18 @@ export class GfHoldingDetailDialogComponent implements OnInit { ); this.tagsAvailable = - this.user?.tags?.map((tag) => { - return { - ...tag, - name: translate(tag.name) - }; - }) ?? []; + this.user?.tags + ?.filter(({ id }) => { + // The "DRAFT" tag qualifies an individual activity and cannot + // be assigned to all activities of a holding + return id !== TAG_ID_DRAFT; + }) + .map((tag) => { + return { + ...tag, + name: translate(tag.name) + }; + }) ?? []; this.changeDetectorRef.markForCheck(); } diff --git a/docs/migrate-is-draft-to-draft-tag.md b/docs/migrate-is-draft-to-draft-tag.md deleted file mode 100644 index aaa5f5634..000000000 --- a/docs/migrate-is-draft-to-draft-tag.md +++ /dev/null @@ -1,287 +0,0 @@ -# Migrating `isDraft` to a `DRAFT` tag - -Follows the precedent set by `Account.isExcluded` → `EXCLUDE_FROM_ANALYSIS`, which retired -that column in two releases: [`79e382a8f`](https://github.com/ghostfolio/ghostfolio/commit/79e382a8f) -made the tag fully functional and deprecated the column, and -[`263e064fd`](https://github.com/ghostfolio/ghostfolio/commit/263e064fd) removed it in a -single migration that backfilled and dropped in one step. - -## Motivation - -`Order.isDraft` is a persisted snapshot of "the date is in the future", computed at write -time in three places that do not agree: - -| Site | Definition | -| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| [activities.service.ts:263](../apps/api/src/app/activities/activities.service.ts#L263) (create) | `FEE/INTEREST/LIABILITY → false`, else `date > endOfToday()` | -| [activities.service.ts:993](../apps/api/src/app/activities/activities.service.ts#L993) (update) | same, **plus** `MANUAL` + `BUY → false` | -| [import.service.ts:734](../apps/api/src/app/import/import.service.ts#L734) (dry run) | `date > endOfToday()`, no type carve-out | - -The `MANUAL` + `BUY` carve-out is accidental. The `if` it lives in is about whether -`SymbolProfile.name` is editable (custom asset profiles), and the `isDraft` computation -merely sits inside its `else`. A custom asset you plan to buy next month is a legitimate -draft, so this carve-out goes away. - -Nothing ever recomputes the column. No job flips a stored `isDraft = true` once the date -passes, while `getActivities` defaults to `includeDrafts: false` -([activities.service.ts:639](../apps/api/src/app/activities/activities.service.ts#L639)). -A future-dated `BUY` is therefore excluded from the portfolio **permanently**, not just -until its date arrives. - -The goal is that the user, not the system, owns this state after creation. A flag -recomputed on every write cannot express that; a tag can. - -## The rule - -> The **Draft** tag is added when an activity's date _newly becomes_ a future date, unless -> its type is FEE, INTEREST or LIABILITY. It is never removed automatically. The user may -> add or remove it freely, on future and past activities alike. - -"Newly becomes" is what makes this one rule rather than separate create and update cases: - -- **create / import** — `date > endOfToday()` -- **update** — `date > endOfToday() && storedDate <= endOfToday()` - -The stored-date comparison is essential. Without it, correcting a fee on a future-dated -activity the user deliberately untagged would silently re-tag it, undoing their choice. - -## Semantics - -`DRAFT` is **stronger** than `EXCLUDE_FROM_ANALYSIS`, not equivalent to it: - -| | Appears in portfolio analysis | Appears in "Excluded" summary | Counted in `activitiesCount` | -| ----------------------- | ----------------------------- | ----------------------------- | ---------------------------- | -| normal | yes | no | yes | -| `EXCLUDE_FROM_ANALYSIS` | no | yes | yes | -| `DRAFT` | no | no | **yes** | - -The guiding principle: **record counts include drafts, money aggregates do not.** -`activitiesCount` answers "how many records are in this account" — a draft is a record. -`totalOfExcludedActivities` answers "how much money" — a draft has not happened. - -Counting drafts also closes a hole in a delete guard. -[accounts-table.component.html:327](../libs/ui/src/lib/accounts-table/accounts-table.component.html#L327) -disables the **Delete account** button via `element.activitiesCount > 0`. An account holding -only drafts reports `0` today, so Delete is enabled; -[deleteAccount](../apps/api/src/app/account/account.service.ts#L202) calls `account.delete()` -with no guard, and `Order.account` is an optional relation with no `onDelete` -([schema.prisma:29](../prisma/schema.prisma#L29)), so Prisma's default `SetNull` silently -detaches those activities. - -It also reconciles two counts that disagree today: `user.activitiesCount` is a raw -`_count.activities` ([user.service.ts:314](../apps/api/src/app/user/user.service.ts#L314)) -and already includes drafts, while the per-account counts do not. - -The two tags therefore stay **independent predicates** — do not group them into a shared -"excluding tags" set. `includeDrafts` survives as a `getActivities` parameter, simply -re-implemented as a tag predicate instead of `where.isDraft = false`. - -This is already today's behaviour: -[portfolio.service.ts:1883](../apps/api/src/app/portfolio/portfolio.service.ts#L1883) -omits `includeDrafts`, so it defaults to `false` and drafts are dropped at the DB before -the excluded / non-excluded split runs. The migration preserves it. - -**Data gathering stays derived from the date, never from a tag.** There is no market price -to fetch for a date that has not happened, so no tag decision may reach that code path. -This is the one piece of `isDraft` that must _not_ become user-owned. - -## Release 1 — introduce the tag, deprecate `isDraft` - -The tag becomes the single source of truth for every read. The column stays in the database -as an exact mirror of the tag, deprecated and unread, so that release 2 is a pure deletion. - -### 1 — Introduce the tag - -- `TAG_ID_DRAFT` in [config.ts](../libs/common/src/lib/config.ts), added to `TAG_IDS_SYSTEM` -- ``DRAFT: $localize`Draft` `` in [i18n.ts](../libs/ui/src/lib/i18n.ts) -- entry in [seed.mts](../prisma/seed.mts) - -The migration creates the `Tag` row and backfills `_OrderToTag` from `isDraft = true`. It -cannot be left to the seed even though `seed.mts` uses `createMany({ skipDuplicates: true })` -and runs on every start — [docker/entrypoint.sh](../docker/entrypoint.sh) runs -`prisma migrate deploy` _before_ `prisma db seed`, so the backfill's foreign key has to -create its own target. - -> **Why the backfill ships here and not with the drop.** Reads move to the tag in this same -> release. Without the backfill, every existing `isDraft = true` row would read as _not_ a -> draft the moment it deploys, and a future-dated `BUY` would re-enter the portfolio as a -> holding the user does not own yet. `isExcluded` could defer its backfill because its -> release 1 read `isExcluded || tag`; this migration reads the tag alone, so the data has to -> be there first. Step 6 is what makes that safe to revert. - -Rename and delete guarding in the admin control panel comes free — -[tags.controller.ts:73](../apps/api/src/app/endpoints/tags/tags.controller.ts#L73) already -rejects anything `isSystemTag` matches. - -### 2 — Make it activity-only - -`DRAFT` is the first system tag not assignable to accounts. - -- **client** — filter it out of `tagsAvailable` in [create-or-update-account-dialog.component.ts](../apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts) -- **server** — no guard exists today. `tagService.validateTagIds` only checks ownership, and - [account.service.ts](../apps/api/src/app/account/account.service.ts) connects `tagIds` - straight through on create and update. Both go through `validateTagIdsForAccount` instead. - -### 3 — Helpers - -Mirroring [account.helper.ts](../apps/api/src/helper/account.helper.ts) field for field: - -- `isDraftActivity({ tags })` in [helper.ts](../libs/common/src/lib/helper.ts), next to - `isAccountExcluded` — the shared read predicate -- `WHERE_ACTIVITY_NOT_DRAFT` in [activity.helper.ts](../apps/api/src/helper/activity.helper.ts), - next to `WHERE_ACCOUNT_NOT_EXCLUDED` — the Prisma equivalent -- `isActivityInFuture({ date })`, next to `isAccountBalanceInFuture` — the date predicate that - keeps data gathering off the tag -- `isDraftTagToBeAssigned({ date, storedDate, type })` — the transition rule above - -### 4 — Auto-assign on write - -Apply the transition rule in `createActivity`, `updateActivity` and the import dry run. - -`updateActivity` needs the stored date to evaluate the rule. The controller already loads the -activity as `originalActivity` to authorize the request -([activities.controller.ts:316](../apps/api/src/app/activities/activities.controller.ts#L316)), -so it passes the date down rather than the service issuing a second query. - -### 5 — Switch reads to the tag - -The DB filter at [activities.service.ts:639](../apps/api/src/app/activities/activities.service.ts#L639) -becomes `WHERE_ACTIVITY_NOT_DRAFT`. - -> **Gotcha:** `where.tags` is already assigned at -> [:766](../apps/api/src/app/activities/activities.service.ts#L766) and `where.OR` at -> [:764](../apps/api/src/app/activities/activities.service.ts#L764). A second assignment -> silently clobbers the first, so this must go through the existing `andConditions` array. - -Field reads to convert: - -- [activities-table.component.ts:280](../libs/ui/src/lib/activities-table/activities-table.component.ts#L280) and [:360](../libs/ui/src/lib/activities-table/activities-table.component.ts#L360) -- badge and ICS gate at [activities-table.component.html:180](../libs/ui/src/lib/activities-table/activities-table.component.html#L180) and [:516](../libs/ui/src/lib/activities-table/activities-table.component.html#L516) -- [portfolio.service.ts:2104](../apps/api/src/app/portfolio/portfolio.service.ts#L2104) - -The two gather gates ([activities.controller.ts:290](../apps/api/src/app/activities/activities.controller.ts#L290) -and [activities.service.ts:1005](../apps/api/src/app/activities/activities.service.ts#L1005)) -convert to `isActivityInFuture` instead — they are date questions, not tag questions. - -In the two count loops the guard **moves** rather than disappears — off the record count, -onto the money sums: - -- [account.service.ts:242](../apps/api/src/app/account/account.service.ts#L242) — - count only, so the loop collapses to `activitiesCount = account.activities.length`. - Its `include` needs nothing added. -- [portfolio.service.ts:195-232](../apps/api/src/app/portfolio/portfolio.service.ts#L195-L232) — - the count becomes unconditional, and the `DIVIDEND` / `INTEREST` cases gain the draft - guard the count gives up (see below). This loop reads raw Prisma rows, so its `include` - at [:178](../apps/api/src/app/portfolio/portfolio.service.ts#L178) needs `tags` added to - `activities` — the existing `tags: true` there is the _account's_ tags, not the - activities'. - -#### Fixing the dividend and interest sums - -Today that loop iterates an unfiltered `include` and gates only the count on `isDraft`. -`dividendInBaseCurrency` and `interestInBaseCurrency` are not gated, so a future-dated -`DIVIDEND` already contributes money the user has not received. Both sums move behind the -draft check, which is what makes the record-vs-money principle hold in both directions. - -`INTEREST` is exempt from auto-assignment, so the guard there only bites when the user -tags an interest activity by hand — applied anyway for consistency. - -### 6 — Reduce `isDraft` to a mirror - -The column is no longer computed from the date at any write site. It is written as -`isDraftActivity({ tags })` over the tag list the same statement persists, which makes it -exact rather than merely close. - -This is what carries the release. It keeps the deprecated field truthful for API consumers -during the deprecation window, and it makes release 1 revertible by deploy rather than by -database restore: roll the code back and the column is still correct for every row, because -every write since the deploy mirrored the tag and the backfill covered everything before it. - -Lifting the computation out of the profile-editability `else` at -[activities.service.ts:991-1005](../apps/api/src/app/activities/activities.service.ts#L991-L1005) -is what un-exempts `MANUAL` + `BUY`. That is safe now precisely because nothing reads the -column any more. - -Mark it in [schema.prisma](../prisma/schema.prisma), as `isExcluded` was: - -```prisma -/// @deprecated Use the "Draft" tag (`TAG_ID_DRAFT`) instead -isDraft Boolean @default(false) -``` - -### 7 — Visibility - -- [user.service.ts:198](../apps/api/src/app/user/user.service.ts#L198) narrows `user.tags` to - _only_ `EXCLUDE_FROM_ANALYSIS` for Basic subscribers. `DRAFT` must be added, or Basic users - get auto-tagged drafts they cannot untag. -- `DRAFT` **stays visible** in the portfolio filter list. The exclusion of - `EXCLUDE_FROM_ANALYSIS` at [portfolio-filter-form.util.ts:110](../libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts#L110) - is not extended to it — filtering for uncertain activities is the point of the tag. - -### 8 — Changelog - -```markdown -### Added - -- Added the _Draft_ tag, assigned automatically to activities dated in the future - -### Changed - -- Deprecated the `isDraft` attribute of the activity in favor of the _Draft_ tag - -### Fixed - -- Fixed the dividend and interest of an account by excluding draft activities -``` - -## Release 2 — remove `isDraft` - -Ships once release 1 has been out and proven. The data moved in release 1, so this migration -only drops: - -```sql --- DropIndex -DROP INDEX "Order_isDraft_idx"; - --- AlterTable -ALTER TABLE "Order" DROP COLUMN "isDraft"; -``` - -Keeping the drop in its own release is the entire point of the split: it is the one step that -cannot be undone by a deploy. - -Then remove the field: - -- `isDraft` and `@@index([isDraft])` from [schema.prisma](../prisma/schema.prisma) -- the mirror writes in `createActivity`, `updateActivity` and the import dry run -- `Omit` list in [export-response.interface.ts:18](../libs/common/src/lib/interfaces/responses/export-response.interface.ts#L18) -- [portfolio-calculator-test-utils.ts:15](../apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts#L15) -- the synthetic `isDraft: false` literals at [activities.service.ts:530](../apps/api/src/app/activities/activities.service.ts#L530) and [import.service.ts:168](../apps/api/src/app/import/import.service.ts#L168) -- 5 occurrences in [activities-table.component.stories.ts](../libs/ui/src/lib/activities-table/activities-table.component.stories.ts) - -`Activity extends Order` loses `isDraft` with the schema. It is dropped from the response -rather than recomputed — the client already receives `tags` and already derives -`isExcludedFromAnalysis` that way, so deriving `isDraft` identically is the consistent end -state. - -Changelog, following the `isExcluded` precedent: - -```markdown -### Changed - -- Removed the deprecated `isDraft` attribute of the activity in favor of the _Draft_ tag including a data migration -``` - -## Behavioural changes to call out in the pull request - -**Every existing draft becomes visible and removable.** Nothing recomputes the column today, -so a future-dated activity whose date has since passed is excluded from the portfolio -**permanently**. After the backfill those rows carry the _Draft_ tag and are still excluded — -but the user can now see why, and remove it. That is a fix, though it will look like a change -to anyone affected. - -**`activitiesCount` starts including drafts**, which changes a visible number in the accounts -table and enables the **Delete account** guard for accounts holding only drafts. - -**A `MANUAL` + `BUY` activity can now become a draft**, where the accidental carve-out -previously prevented it on update.