module transaction.service;

import handy_http_primitives;
import streams : isByteOutputStream;
import std.datetime;
import slf4d;

import transaction.api;
import transaction.model;
import transaction.data;
import transaction.dto;
import transaction.search_filters : extractSearchParams;
import profile.data;
import account.model;
import account.data;
import util.money;
import util.pagination;
import util.data;
import util.validation.transaction;
import util.validation.draft;
import attachment.data;
import attachment.dto;

// Transactions Services

Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
    return ds.getTransactionRepository()
        .findAll(pageRequest);
}

TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
    import std.algorithm : map;
    import std.array : array;
    TransactionDetail txn = ds.getTransactionRepository().findById(transactionId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    txn.attachments = ds.getAttachmentRepository().findAllByTransactionId(txn.id)
        .map!(AttachmentResponse.of)
        .array;
    return txn;
}

TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload, in MultipartFile[] files) {
    TransactionRepository txnRepo = ds.getTransactionRepository();
    TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
    TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
    AccountRepository accountRepo = ds.getAccountRepository();
    AttachmentRepository attachmentRepo = ds.getAttachmentRepository();

    validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
    SysTime now = Clock.currTime(UTC());
    SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());

    // Add the transaction:
    ulong txnId;
    ds.doTransaction(() {
        TransactionDetail txn = txnRepo.insert(payload);
        AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
        if (payload.creditedAccountId) {
            jeRepo.insert(
                timestamp,
                payload.creditedAccountId.value,
                txn.id,
                txn.amount,
                AccountJournalEntryType.CREDIT,
                txn.currency
            );
        }
        if (payload.debitedAccountId) {
            jeRepo.insert(
                timestamp,
                payload.debitedAccountId.value,
                txn.id,
                txn.amount,
                AccountJournalEntryType.DEBIT,
                txn.currency
            );
        }
        TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
        tagRepo.updateTags(txn.id, payload.tags);
        updateAttachments(txn.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo);
        txnId = txn.id;
    });
    return getTransaction(ds, txnId);
}

TransactionDetail updateTransaction(
    ProfileDataSource ds,
    ulong transactionId,
    in AddTransactionPayload payload,
    in MultipartFile[] files
) {
    TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
    TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
    AccountRepository accountRepo = ds.getAccountRepository();
    TransactionRepository transactionRepo = ds.getTransactionRepository();
    TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
    AttachmentRepository attachmentRepo = ds.getAttachmentRepository();

    validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
    SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
    SysTime now = Clock.currTime(UTC());

    const TransactionDetail prev = transactionRepo.findById(transactionId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));

    // Update the transaction:
    ds.doTransaction(() {
        TransactionDetail curr = transactionRepo.update(transactionId, payload);
        updateLinkedAccountJournalEntries(prev, curr, payload, ds, timestamp);
        tagRepo.updateTags(transactionId, payload.tags);
        updateAttachments(curr.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo);
    });
    return getTransaction(ds, transactionId);
}

private void updateLinkedAccountJournalEntries(
    in TransactionDetail prev,
    in TransactionDetail curr,
    in AddTransactionPayload payload,
    ProfileDataSource ds,
    in SysTime timestamp
) {
    AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
    const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
    const bool updateCreditEntry = amountOrCurrencyChanged || (
        (!prev.creditedAccount && payload.creditedAccountId) ||
        (prev.creditedAccount && !payload.creditedAccountId) ||
        (
            prev.creditedAccount &&
            payload.creditedAccountId &&
            prev.creditedAccount.value.id != payload.creditedAccountId.value
        )
    );
    const bool updateDebitEntry = amountOrCurrencyChanged || (
        (!prev.debitedAccount && payload.creditedAccountId) ||
        (prev.debitedAccount && !payload.debitedAccountId) ||
        (
            prev.debitedAccount &&
            payload.debitedAccountId &&
            prev.debitedAccount.value.id != payload.debitedAccountId.value
        )
    );

    // Update journal entries if necessary:
    if (updateCreditEntry && prev.creditedAccount) {
        jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.value.id, prev.id);
    }
    if (updateCreditEntry && payload.creditedAccountId) {
        jeRepo.insert(
            timestamp,
            payload.creditedAccountId.value,
            curr.id,
            curr.amount,
            AccountJournalEntryType.CREDIT,
            curr.currency
        );
    }
    if (updateDebitEntry && prev.debitedAccount) {
        jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.value.id, prev.id);
    }
    if (updateDebitEntry && payload.debitedAccountId) {
        jeRepo.insert(
            timestamp,
            payload.debitedAccountId.value,
            curr.id,
            curr.amount,
            AccountJournalEntryType.DEBIT,
            curr.currency
        );
    }
}

void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
    TransactionRepository txnRepo = ds.getTransactionRepository();
    AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
    TransactionDetail txn = txnRepo.findById(transactionId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    ds.doTransaction(() {
        // First delete all attachments.
        foreach (a; attachmentRepo.findAllByTransactionId(txn.id)) {
            attachmentRepo.remove(a.id);
        }
        txnRepo.deleteById(txn.id);
    });
}

/** 
 * Helper function to add / remove attachments for a transaction.
 */
void updateAttachments(
    ulong transactionId,
    SysTime timestamp,
    in ulong[] attachmentIdsToRemove,
    in MultipartFile[] attachmentsToAdd,
    AttachmentRepository attachmentRepo,
    TransactionRepository txnRepo
) {
    // Save & link attachment files:
    foreach (file; attachmentsToAdd) {
        ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content);
        txnRepo.linkAttachment(transactionId, attachmentId);
    }
    // Delete attachments (this cascades to delete the link record in transaction_attachment).
    foreach (idToRemove; attachmentIdsToRemove) {
        attachmentRepo.remove(idToRemove);
    }
}

/**
 * Exports transaction data to a file for download.
 * Params:
 *   ds = The profile datasource to use.
 *   request = The request to read filter parameters from.
 *   response = The response to write the file to.
 */
void exportTransactionsToFile(
    ProfileDataSource ds,
    in ServerHttpRequest request,
    ref ServerHttpResponse response
) {
    Page!TransactionsListItem data = ds.getTransactionRepository()
        .search(PageRequest.unpaged(), extractSearchParams(request));
    ExportFileFormat fileFormat = getPreferredExportFileFormat(request);
    if (fileFormat == ExportFileFormat.JSON) {
        import handy_http_data : writeJsonBody;
        addFileExportHeaders(response, "transactions.json", ContentTypes.APPLICATION_JSON);
        writeJsonBody(response, data.items);
    } else if (fileFormat == ExportFileFormat.CSV) {
        addFileExportHeaders(response, "transactions.csv", ContentTypes.TEXT_CSV);
        writeTransactionsCsvExport(&response.outputStream, data.items);
    } else {
        throw new HttpStatusException(
            HttpStatus.BAD_REQUEST,
            "Invalid file export format requested. JSON or CSV are permitted."
        );
    }
}

private void writeTransactionsCsvExport(S)(
    S outputStream,
    in TransactionsListItem[] items
) if (isByteOutputStream!S) {
    import util.csv;
    import std.format : format;
    import std.conv : to;
    import std.string : join;
    CsvStreamWriter!S csv = CsvStreamWriter!(S)(outputStream);
    csv
        .append("ID")
        .append("Timestamp")
        .append("Added to Finnow")
        .append("Amount")
        .append("Currency")
        .append("Description")
        .append("Internal Transfer")
        .append("Vendor")
        .append("Category")
        .append("Credited Account")
        .append("Credited Account Type")
        .append("Credited Account Number")
        .append("Debited Account")
        .append("Debited Account Type")
        .append("Debited Account Number")
        .append("tags")
        .newLine();
    
    foreach (item; items) {
        string tagsStr = join(item.tags, ",");
        if (tagsStr.length > 0) {
            tagsStr = "\"" ~ tagsStr ~ "\"";
        }
        csv
            .append(item.id)
            .append(item.timestamp)
            .append(item.addedAt)
            .append(format(
                "%." ~ item.currency.fractionalDigits.to!string ~ "f",
                MoneyValue(item.currency, item.amount).toFloatingPoint()
            ))
            .append(item.currency.code)
            .append(item.description)
            .append(item.internalTransfer)
            .append(item.vendor.mapIfPresent!(v => v.name).orElse(null))
            .append(item.category.mapIfPresent!(c => c.name).orElse(null))
            .append(item.creditedAccount.mapIfPresent!(a => a.name).orElse(null))
            .append(item.creditedAccount.mapIfPresent!(a => a.type).orElse(null))
            .append(item.creditedAccount.mapIfPresent!(a => "#" ~ a.numberSuffix).orElse(null))
            .append(item.debitedAccount.mapIfPresent!(a => a.name).orElse(null))
            .append(item.debitedAccount.mapIfPresent!(a => a.type).orElse(null))
            .append(item.debitedAccount.mapIfPresent!(a => "#" ~ a.numberSuffix).orElse(null))
            .append(tagsStr)
            .newLine();
    }
}

// Vendors Services

TransactionVendor[] getAllVendors(ProfileDataSource ds) {
    return ds.getTransactionVendorRepository().findAll();
}

TransactionVendor getVendor(ProfileDataSource ds, ulong vendorId) {
    return ds.getTransactionVendorRepository().findById(vendorId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}

TransactionVendor createVendor(ProfileDataSource ds, in VendorPayload payload) {
    auto vendorRepo = ds.getTransactionVendorRepository();
    if (vendorRepo.existsByName(payload.name)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor name is already in use.");
    }
    return vendorRepo.insert(payload.name, payload.description);
}

TransactionVendor updateVendor(ProfileDataSource ds, ulong vendorId, in VendorPayload payload) {
    TransactionVendorRepository repo = ds.getTransactionVendorRepository();
    TransactionVendor existingVendor = repo.findById(vendorId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    if (payload.name != existingVendor.name && repo.existsByName(payload.name)) {
        throw new HttpStatusException(
            HttpStatus.BAD_REQUEST,
            "Vendor name is already in use."
        );
    }
    return repo.updateById(vendorId, payload.name, payload.description);
}

void deleteVendor(ProfileDataSource ds, ulong vendorId) {
    ds.getTransactionVendorRepository().deleteById(vendorId);
}

// Categories Services

TransactionCategoryTree[] getCategories(ProfileDataSource ds) {
    TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
    return getCategoriesRecursive(repo, Optional!ulong.empty, 0);
}

private TransactionCategoryTree[] getCategoriesRecursive(
    TransactionCategoryRepository repo,
    Optional!ulong parentId,
    uint depth
) {
    import util.data : toNullable;
    TransactionCategoryTree[] nodes;
    foreach (category; repo.findAllByParentId(parentId)) {
        nodes ~= TransactionCategoryTree(
            category.id,
            parentId,
            category.name,
            category.description,
            category.color,
            getCategoriesRecursive(repo, Optional!ulong.of(category.id), depth + 1),
            depth
        );
    }
    return nodes;
}

TransactionCategoryResponse getCategory(ProfileDataSource ds, ulong categoryId) {
    auto category = ds.getTransactionCategoryRepository().findById(categoryId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    return TransactionCategoryResponse.of(category);
}

TransactionCategoryResponse[] getChildCategories(ProfileDataSource ds, ulong categoryId) {
    import std.algorithm : map;
    import std.array : array;
    auto categories = ds.getTransactionCategoryRepository().findAllByParentId(Optional!ulong.of(categoryId));
    return categories.map!(c => TransactionCategoryResponse.of(c)).array;
}

TransactionCategoryBalance[] getCategoryBalances(ProfileDataSource ds, ulong categoryId, bool includeChildren) {
    return ds.getTransactionCategoryRepository().getBalance(
        categoryId,
        includeChildren,
        Optional!SysTime.empty(),
        Optional!SysTime.empty()
    );
}

TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayload payload) {
    TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
    if (payload.name is null || payload.name.length == 0) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing name.");
    }
    if (repo.existsByName(payload.name)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
    }
    if (payload.parentId && !repo.existsById(payload.parentId.value)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
    }
    import std.regex;
    const colorHexRegex = ctRegex!`^(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$`;
    if (payload.color is null || matchFirst(payload.color, colorHexRegex).empty) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string.");
    }
    auto category = repo.insert(
        payload.parentId,
        payload.name,
        payload.description,
        payload.color
    );
    return TransactionCategoryResponse.of(category);
}

TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryId, in CategoryPayload payload) {
    TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
    if (payload.name is null || payload.name.length == 0) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing name.");
    }
    TransactionCategory prev = repo.findById(categoryId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    if (payload.name != prev.name && repo.existsByName(payload.name)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
    }
    if (payload.parentId && !repo.existsById(payload.parentId.value)) {
        throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
    }
    TransactionCategory curr = repo.updateById(
        categoryId,
        payload.name,
        payload.description,
        payload.color,
        payload.parentId
    );
    return TransactionCategoryResponse.of(curr);
}

void deleteCategory(ProfileDataSource ds, ulong categoryId) {
    ds.getTransactionCategoryRepository().deleteById(categoryId);
}

// Draft services

Page!TransactionDraftListItem getDrafts(ProfileDataSource ds, in PageRequest pr) {
    return ds.getTransactionDraftRepository().findAll(pr);
}

TransactionDraftResponse getDraft(ProfileDataSource ds, ulong draftId) {
    return ds.getTransactionDraftRepository()
        .findById(draftId)
        // Populate the list of attachments for the draft.
        .mapIfPresent!((draft) {
            import std.algorithm : map;
            import std.array : array;
            draft.attachments = ds.getAttachmentRepository()
                .findAllByTransactionDraftId(draft.id)
                .map!(AttachmentResponse.of)
                .array;
            return draft;
        })
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}

TransactionDraftResponse addDraft(ProfileDataSource ds, in TransactionDraftPayload payload, in MultipartFile[] files) {
    TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
    AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
    
    validateDraftPayload(
        ds.getTransactionVendorRepository(),
        ds.getTransactionCategoryRepository(),
        ds.getAccountRepository(),
        payload
    );
    SysTime now = Clock.currTime(UTC());

    ulong draftId;
    ds.doTransaction(() {
        TransactionDraftResponse draft = draftRepo.insert(payload);
        draftRepo.updateTags(draft.id, payload.tags);
        updateDraftAttachments(draft.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, draftRepo);
        draftId = draft.id;
    });
    return getDraft(ds, draftId);
}

TransactionDraftResponse updateDraft(
    ProfileDataSource ds,
    ulong draftId,
    in TransactionDraftPayload payload,
    in MultipartFile[] files
) {
    TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
    AttachmentRepository attachmentRepo = ds.getAttachmentRepository();

    validateDraftPayload(
        ds.getTransactionVendorRepository(),
        ds.getTransactionCategoryRepository(),
        ds.getAccountRepository(),
        payload
    );
    SysTime now = Clock.currTime(UTC());

    ds.doTransaction(() {
        draftRepo.update(draftId, payload);
        draftRepo.updateTags(draftId, payload.tags);
        updateDraftAttachments(draftId, now, payload.attachmentIdsToRemove, files, attachmentRepo, draftRepo);
    });
    return getDraft(ds, draftId);
}

void deleteDraft(ProfileDataSource ds, ulong draftId) {
    TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
    AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
    TransactionDraftResponse draft = draftRepo.findById(draftId)
        .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
    ds.doTransaction(() {
        // First delete all attachments.
        foreach (a; attachmentRepo.findAllByTransactionDraftId(draft.id)) {
            attachmentRepo.remove(a.id);
        }
        draftRepo.deleteById(draft.id);
    });
}

private void updateDraftAttachments(
    ulong draftId,
    SysTime timestamp,
    in ulong[] attachmentIdsToRemove,
    in MultipartFile[] attachmentsToAdd,
    AttachmentRepository attachmentRepo,
    TransactionDraftRepository draftRepo
) {
    foreach (file; attachmentsToAdd) {
        ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content);
        draftRepo.linkAttachment(draftId, attachmentId);
    }
    foreach (idToRemove; attachmentIdsToRemove) {
        attachmentRepo.remove(idToRemove);
    }
}

// Recurring Transactions:

Page!RecurringTransactionResponse getRecurringTransactions(ProfileDataSource ds, PageRequest pr) {
    return ds.getRecurringTransactionRepository().findAll(pr);
}

RecurringTransactionResponse createRecurringTransaction(ProfileDataSource ds, in RecurringTransactionPayload payload) {
    return ds.getRecurringTransactionRepository()
        .insert(payload);
}

void deleteRecurringTransaction(ProfileDataSource ds, ulong id) {
    ds.getRecurringTransactionRepository()
        .deleteById(id);
}

RecurringTransactionResponse[] getRecurringTransactionsForDraft(ProfileDataSource ds, ulong draftId) {
    return ds.getRecurringTransactionRepository()
        .findAllByDraftId(draftId);
}

/**
 * A routine used by a scheduled job that runs periodically for all users, and
 * all those users' profiles. It iterates over every one of the user's recurring
 * transactions and does the following:
 *  - Adds it to the persistent queue with the next timestamp if it's not
 *    already there.
 *  - Creates a new draft transaction from the recurring transaction's linked
 *    template, if its queued timestamp has elapsed. Then requeues that
 *    recurring transaction for its next timestamp.
 * Params:
 *   ds = The profile data source to operate on.
 */
void updateRecurringTransactionScheduling(ProfileDataSource ds) {
    import cronexp;

    RecurringTransactionRepository rtRepo = ds.getRecurringTransactionRepository();
    Page!(RecurringTransactionResponse) result = rtRepo.findAll(PageRequest.unpaged());
    const DateTime NOW = cast(DateTime) Clock.currTime(UTC());
    ds.doTransaction(() {
        foreach (RecurringTransactionResponse rt; result.items) {
            auto cron = CronExpr(rt.scheduleExpr);
            auto nextScheduledOccurrence = cron.getNext(NOW);
            if (nextScheduledOccurrence.isNull) {
                rtRepo.deleteById(rt.id);
            } else {
                // First, check if it's already in queue to execute (and execute it if it's time).
                Optional!string queuedTimestampStr = rtRepo.findInQueue(rt.id);
                bool shouldRequeue = false;
                if (queuedTimestampStr) {
                    DateTime queuedTimestamp = DateTime.fromISOExtString(queuedTimestampStr.value);
                    if (queuedTimestamp < NOW) {
                        createDraftForRecurringTransaction(ds, rt, queuedTimestamp);
                        rtRepo.removeFromQueue(rt.id);
                        shouldRequeue = true;
                    } else if (queuedTimestamp != nextScheduledOccurrence) {
                        // The queued timestamp doesn't match the next scheduled occurrence.
                        // This shouldn't really happen at all, but if it does, we remove it
                        // so the proper scheduled timestamp can be added.
                        warnF!("Found a recurring transaction whose queued timestamp " ~ 
                            "doesn't match the next scheduled occurrence as per its " ~
                            "Cron schedule expression. Removing that queued transaction " ~
                            "and requeueing it using the Cron expression's next date. " ~
                            "Recurring Transaction ID: %d; Cron Expression: %s")(rt.id, rt.scheduleExpr);
                        rtRepo.removeFromQueue(rt.id);
                        shouldRequeue = true;
                    }
                } else {
                    shouldRequeue = true;
                }

                if (shouldRequeue) {
                    const nextTimestamp = nextScheduledOccurrence.get().toISOExtString();
                    infoF!"Requeueing recurring transaction %d to occur next at %s."(rt.id, nextTimestamp);
                    rtRepo.addToQueue(rt.id, nextTimestamp);
                }
            }
        }
    });
}

private void createDraftForRecurringTransaction(
    ProfileDataSource ds,
    in RecurringTransactionResponse rt,
    DateTime queuedTimestamp
) {
    TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository();
    Optional!TransactionDraftResponse draftOpt = draftRepo.findById(rt.draftId);
    if (!draftOpt) {
        warnF!"Couldn't create draft for recurring transaction %d because template draft %d doesn't exist."(
            rt.id, rt.draftId
        );
        return;
    }
    TransactionDraftResponse templateDraft = draftOpt.value;
    TransactionDraftPayload payload;
    payload.timestamp = queuedTimestamp.toISOExtString().toOptional();
    payload.amount = templateDraft.amount;
    payload.currencyCode = templateDraft.currency.mapIfPresent!(c => c.code.idup);
    string description = templateDraft.description.orElse("")
        ~ "\n- Created automatically as a recurring transaction from template: \"" ~
        templateDraft.templateName.orElse("Unknown") ~ "\".";
    payload.description = description.toOptional;
    payload.internalTransfer = templateDraft.internalTransfer;
    payload.vendorId = templateDraft.vendor.mapIfPresent!(v => v.id);
    payload.categoryId = templateDraft.category.mapIfPresent!(c => c.id);
    payload.creditedAccountId = templateDraft.creditedAccount.mapIfPresent!(a => a.id);
    payload.debitedAccountId = templateDraft.debitedAccount.mapIfPresent!(a => a.id);
    payload.tags = templateDraft.tags;
    import std.algorithm : map;
    import std.array : array;
    payload.lineItems = templateDraft.lineItems.map!(li => TransactionDraftPayload.LineItemPayload(
        li.valuePerItem,
        li.quantity,
        li.description,
        li.category.mapIfPresent!(c => c.id)
    )).array;
    auto createdDraft = draftRepo.insert(payload);
    draftRepo.updateTags(createdDraft.id, payload.tags);
    infoF!"Inserted draft %d for scheduled recurring transaction %d."(createdDraft.id, rt.id);
}
