You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

15 KiB

Migrating isDraft to a DRAFT tag

Follows the precedent set by Account.isExcludedEXCLUDE_FROM_ANALYSIS, which retired that column in two releases: 79e382a8f made the tag fully functional and deprecated the column, and 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 (create) FEE/INTEREST/LIABILITY → false, else date > endOfToday()
activities.service.ts:993 (update) same, plus MANUAL + BUY → false
import.service.ts:734 (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). 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 / importdate > endOfToday()
  • updatedate > 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 disables the Delete account button via element.activitiesCount > 0. An account holding only drafts reports 0 today, so Delete is enabled; deleteAccount calls account.delete() with no guard, and Order.account is an optional relation with no onDelete (schema.prisma:29), 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) 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 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

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 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 already rejects anything isSystemTag matches.

2 — Make it activity-only

DRAFT is the first system tag not assignable to accounts.

3 — Helpers

Mirroring account.helper.ts field for field:

  • isDraftActivity({ tags }) in helper.ts, next to isAccountExcluded — the shared read predicate
  • WHERE_ACTIVITY_NOT_DRAFT in 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), 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 becomes WHERE_ACTIVITY_NOT_DRAFT.

Gotcha: where.tags is already assigned at :766 and where.OR at :764. A second assignment silently clobbers the first, so this must go through the existing andConditions array.

Field reads to convert:

The two gather gates (activities.controller.ts:290 and activities.service.ts:1005) 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 — count only, so the loop collapses to activitiesCount = account.activities.length. Its include needs nothing added.
  • portfolio.service.ts:195-232 — 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 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 is what un-exempts MANUAL + BUY. That is safe now precisely because nothing reads the column any more.

Mark it in schema.prisma, as isExcluded was:

/// @deprecated Use the "Draft" tag (`TAG_ID_DRAFT`) instead
isDraft Boolean @default(false)

7 — Visibility

  • user.service.ts:198 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 is not extended to it — filtering for uncertain activities is the point of the tag.

8 — Changelog

### 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:

-- 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:

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:

### 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.