Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9974242
経常経費のXMLエクスポート閾値を10万円から5万円に変更
claude Apr 6, 2026
8a29182
Merge pull request #1192 from team-mirai/claude/review-expense-thresh…
jujunjun110 Apr 6, 2026
b70f2ac
fix: 報告書プロフィール編集で年度切り替え時に前の年度のデータで上書きされるバグを修正
claude Apr 17, 2026
efa7743
test: 報告書プロフィール年度切り替えバグの再発防止E2Eテストを追加
claude Apr 17, 2026
e1c494e
Merge pull request #1200 from team-mirai/claude/fix-profile-year-bug-…
jujunjun110 Apr 17, 2026
35a74cb
docs: add SECURITY.md with vulnerability reporting policy
claude May 12, 2026
3a167b4
Merge pull request #1202 from team-mirai/claude/add-security-policy-w…
jujunjun110 May 12, 2026
5bb6395
feat: 現金・仮払金・立替金の勘定科目を有効化
claude Jun 22, 2026
5f97046
chore: trigger CI re-run
claude Jun 22, 2026
f4ca775
fix(ci): postinstall でのブラウザ並列インストール衝突を解消
claude Jun 22, 2026
1544f2a
Merge pull request #1208 from team-mirai/claude/practical-faraday-j8rf8q
jujunjun110 Jun 22, 2026
99649ff
fix(webapp): 個人の負担する党費又は会費を独立した収入カテゴリに分類
claude Jun 22, 2026
b1d828b
Merge pull request #1209 from team-mirai/claude/wizardly-pasteur-fvhp2z
jujunjun110 Jun 22, 2026
98444b3
feat: 非現金BS科目(未払金・仮払金・仮受金)の取引を収支として認識する
jujunjun110 Jun 24, 2026
2ce8c1d
fix: BS_CATEGORIESの仮払金重複を解消
jujunjun110 Jun 24, 2026
48c5883
fix: developで追加されたテストの期待値を修正、立替金をSQLマイグレーション対象に追加
jujunjun110 Jun 24, 2026
910d780
Merge pull request #1210 from team-mirai/feature/support-non-cash-bs-…
jujunjun110 Jun 24, 2026
1a40423
fix: BS_CATEGORIESの仮払金重複を解消
jujunjun110 Jun 24, 2026
defb473
fix: developで追加されたテストの期待値を修正、立替金をSQLマイグレーション対象に追加
jujunjun110 Jun 24, 2026
891f6f6
fix: 非CASH BS(借方)/PL費用科目(貸方)の仕訳をexpenseとして正しく認識する
jujunjun110 Jun 24, 2026
cd69762
Merge pull request #1211 from team-mirai/fix/non-cash-journal-expense…
jujunjun110 Jun 24, 2026
5e41357
fix: サンキー図でBS科目(仮払金等)を収入・支出ノードから除外
jujunjun110 Jun 26, 2026
284233d
fix: 勘定科目の判定を Object.hasOwn でプロトタイプ汚染に対して安全にする
jujunjun110 Jun 26, 2026
571d71c
Merge pull request #1212 from team-mirai/fix/exclude-bs-accounts-from…
jujunjun110 Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# セキュリティポリシー

## 脆弱性の報告

team-mirai/marumie におけるセキュリティ脆弱性を発見された場合は、
GitHub の Private Vulnerability Reporting よりご報告ください。

報告フォーム: https://github.com/team-mirai/marumie/security/advisories/new

公開 Issue や Pull Request、SNS 等で詳細を共有する前に、
上記の非公開チャネルからご連絡いただけますと幸いです。

## 対応の流れ

1. 報告を受領次第、メンテナが内容を確認します (初回応答の目安: 5 営業日以内)
2. 影響範囲・再現性の確認、修正方針の検討を行います
3. 修正をリリースし、必要に応じて Security Advisory を公開します

## 対象範囲

本ポリシーは team-mirai/marumie リポジトリおよび、
本リポジトリのコードからデプロイされている公開アプリケーションを対象とします。

## 謝辞

責任ある開示にご協力いただいた報告者の方々に感謝いたします。
87 changes: 87 additions & 0 deletions admin/e2e/tests/report-profile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { test, expect } from "@playwright/test";

test.describe("報告書プロフィール", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("foo@example.com");
await page.getByLabel("Password").fill("foo@example.com");
await page.getByRole("button", { name: "ログイン" }).click();
await expect(page).toHaveURL("/");
});

test.describe("年度切り替え", () => {
test("年度を切り替えてもフォームが正しく同期し、他の年度を上書きしない", async ({ page }) => {
// 既存シードや他テストと干渉しないよう、テスト専用の政治団体を作成
const uniqueSlug = `report-profile-year-${Date.now()}`;
const orgName = `年度切替テスト団体 ${Date.now()}`;

await page.goto("/political-organizations/new");
await page.getByLabel(/表示名/).fill(orgName);
await page.getByLabel(/スラッグ/).fill(uniqueSlug);
await page.getByRole("button", { name: "作成" }).click();
await expect(page).toHaveURL("/political-organizations");

// 作成した政治団体の報告書プロフィール画面へ遷移
const orgCard = page
.locator("h3")
.filter({ hasText: orgName })
.locator("xpath=ancestor::div[contains(@class, 'border')]");
await orgCard.getByRole("link", { name: "編集" }).click();
await page.getByRole("link", { name: "報告書プロフィール" }).click();

await expect(
page.getByRole("heading", { name: new RegExp(`${orgName}.*報告書プロフィール`) }),
).toBeVisible();

// YearSelector の select(Label「報告年」の直後の要素)
const yearSelect = page.locator(
'xpath=//label[normalize-space()="報告年"]/following-sibling::select[1]',
);

// YearSelector の選択肢は currentYear から過去10年。
// 実行年に依存しないよう、currentYear-1 と currentYear-2 を使う。
const options = await yearSelect.locator("option").all();
const yearA = await options[1].getAttribute("value");
const yearB = await options[2].getAttribute("value");
expect(yearA).toBeTruthy();
expect(yearB).toBeTruthy();

// 団体名称は Label に htmlFor が無いため、placeholder で一意に特定
const officialNameInput = page.getByPlaceholder("政治団体の正式名称");
const saveButton = page.getByRole("button", { name: /^保存/ });

// yearA で新規保存
await yearSelect.selectOption(yearA!);
await expect(page).toHaveURL(new RegExp(`year=${yearA}`));
await expect(officialNameInput).toHaveValue("");

const yearAName = `${yearA}年の団体名 ${Date.now()}`;
await officialNameInput.fill(yearAName);
await saveButton.click();
await expect(page.getByText("保存しました")).toBeVisible();

// yearB に切り替え → フォームが空になっていること
// (バグ再発時は yearA の入力値が残り、保存で yearA が上書きされる)
await yearSelect.selectOption(yearB!);
await expect(page).toHaveURL(new RegExp(`year=${yearB}`));
await expect(officialNameInput).toHaveValue("");

// yearB で別の値を保存
const yearBName = `${yearB}年の団体名 ${Date.now()}`;
await officialNameInput.fill(yearBName);
await saveButton.click();
await expect(page.getByText("保存しました")).toBeVisible();

// yearA に戻す → yearA の保存内容が残っていること
// (バグ再発時は yearB の保存で yearA が上書きされて yearBName が表示される)
await yearSelect.selectOption(yearA!);
await expect(page).toHaveURL(new RegExp(`year=${yearA}`));
await expect(officialNameInput).toHaveValue(yearAName);

// yearB に戻す → yearB の保存内容も残っていること
await yearSelect.selectOption(yearB!);
await expect(page).toHaveURL(new RegExp(`year=${yearB}`));
await expect(officialNameInput).toHaveValue(yearBName);
});
});
});
2 changes: 1 addition & 1 deletion admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"private": true,
"version": "0.0.0",
"scripts": {
"postinstall": "pnpm exec playwright install chromium",
"dev": "next dev --turbopack --port 3001",
"build": "next build",
"start": "next start",
Expand All @@ -12,6 +11,7 @@
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"pretest:e2e": "pnpm exec playwright install chromium",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0",
"test:e2e:headed": "playwright test --headed"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default async function ReportProfilePage({ params, searchParams }: Report
</div>

<ReportProfileForm
key={financialYear}
politicalOrganizationId={orgId}
financialYear={financialYear}
initialData={profile}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function CounterpartAssignmentFilters({
</ul>
</div>
<div className="space-y-1">
<p className="font-medium">【経常経費】10万円以上</p>
<p className="font-medium">【経常経費】5万円以上</p>
<ul className="list-disc list-inside text-xs">
<li>光熱水費</li>
<li>備品・消耗品費</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ interface ExpenseTableProps {

function ExpenseTable({ rows }: ExpenseTableProps) {
if (rows.length === 0) {
return <p className="text-gray-500 text-sm">10万円以上の明細はありません</p>;
return <p className="text-gray-500 text-sm">5万円以上の明細はありません</p>;
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function SectionWrapper({
formId,
totalAmount,
underThresholdAmount,
thresholdLabel = "10万円未満の合計",
thresholdLabel = "5万円未満の合計",
isEmpty = false,
children,
}: SectionWrapperProps) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,8 @@ export class TransactionValidator {
transaction.debit_account === OFFSET_EXPENSE_ACCOUNT ||
transaction.credit_account === OFFSET_INCOME_ACCOUNT;

const isNonCashTransaction = transaction.transaction_type === "non_cash_journal";

if (
!isOffsetTransaction &&
!isNonCashTransaction &&
(!transaction.friendly_category || transaction.friendly_category.trim() === "")
) {
return "独自のカテゴリが設定されていません";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ export class MfRecordConverter {
}

private determineCategoryKey(debitAccount: string, creditAccount: string): string {
const isDebitPL = debitAccount in PL_CATEGORIES;
const isCreditPL = creditAccount in PL_CATEGORIES;
const isDebitPL = Object.hasOwn(PL_CATEGORIES, debitAccount);
const isCreditPL = Object.hasOwn(PL_CATEGORIES, creditAccount);

if (isDebitPL) {
const mapping = PL_CATEGORIES[debitAccount];
Expand All @@ -130,10 +130,10 @@ export class MfRecordConverter {
return "offset_income";
}

const isDebitBS = debitAccount in BS_CATEGORIES;
const isCreditBS = creditAccount in BS_CATEGORIES;
const isDebitPL = debitAccount in PL_CATEGORIES;
const isCreditPL = creditAccount in PL_CATEGORIES;
const isDebitBS = Object.hasOwn(BS_CATEGORIES, debitAccount);
const isCreditBS = Object.hasOwn(BS_CATEGORIES, creditAccount);
const isDebitPL = Object.hasOwn(PL_CATEGORIES, debitAccount);
const isCreditPL = Object.hasOwn(PL_CATEGORIES, creditAccount);

// 現金収入: 現金類(借方) + PL科目(貸方)
if (isDebitBS && isCreditPL && this.isCashEquivalent(debitAccount)) {
Expand All @@ -143,9 +143,12 @@ export class MfRecordConverter {
if (isDebitPL && isCreditBS && this.isCashEquivalent(creditAccount)) {
return "expense";
}
// 非現金仕訳: PL科目とBS科目の組み合わせ(現金を含まない)
if ((isDebitPL && isCreditBS) || (isDebitBS && isCreditPL)) {
return "non_cash_journal";
// 発生主義: PL科目が登場した仕訳タイミングで収支を認識(現金を含まない場合)
if (isDebitPL && isCreditBS) {
return "expense";
}
if (isDebitBS && isCreditPL) {
return PL_CATEGORIES[creditAccount]?.type === "expense" ? "expense" : "income";
}

return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const COUNTERPART_REQUIRED_INCOME_CATEGORIES = [
] as const;

/**
* 経常経費カテゴリ(SYUUSHI07_14)- 閾値10万円
* 経常経費カテゴリ(SYUUSHI07_14)- 閾値5万円
*/
export const ROUTINE_EXPENSE_CATEGORIES = [
// biome-ignore lint/complexity/useLiteralKeys: 日本語キー
Expand Down Expand Up @@ -79,11 +79,11 @@ type CounterpartRequiredExpenseCategory = (typeof COUNTERPART_REQUIRED_EXPENSE_C
* 支払先の氏名・住所を明細に記載する必要があります。
*
* 参考: docs/report_format.md
* - 経常経費(SYUUSHI07_14): 10万円以上
* - 経常経費(SYUUSHI07_14): 5万円以上
* - 政治活動費(SYUUSHI07_15): 5万円以上
* - 収入(借入金・交付金): 閾値なし(すべて記載)
*/
export const ROUTINE_EXPENSE_THRESHOLD = 100_000;
export const ROUTINE_EXPENSE_THRESHOLD = 50_000;
export const POLITICAL_ACTIVITY_EXPENSE_THRESHOLD = 50_000;

/**
Expand Down Expand Up @@ -148,8 +148,8 @@ function getThresholdForCategory(categoryKey: string): number {
*
* @example
* ```typescript
* isAboveDetailThreshold('expense_office_expenses', 150_000) // true(経常経費、10万円以上
* isAboveDetailThreshold('expense_office_expenses', 50_000) // false(経常経費、10万円未満
* isAboveDetailThreshold('expense_office_expenses', 150_000) // true(経常経費、5万円以上
* isAboveDetailThreshold('expense_office_expenses', 50_000) // true(経常経費、5万円以上
* isAboveDetailThreshold('expense_organization_activities', 50_000) // true(政治活動費、5万円以上)
* ```
*/
Expand All @@ -173,7 +173,7 @@ export function isAboveDetailThreshold(categoryKey: string, amount: number): boo
* @example
* ```typescript
* requiresCounterpartDetail('expense', 'expense_office_expenses', 150_000) // true
* requiresCounterpartDetail('expense', 'expense_office_expenses', 50_000) // false(閾値未満
* requiresCounterpartDetail('expense', 'expense_office_expenses', 50_000) // true(閾値以上
* requiresCounterpartDetail('income', 'income_donation_individual', 150_000) // false(寄附は対象外)
* ```
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
sanitizeText,
buildBikou,
isAboveThreshold,
TEN_MAN_THRESHOLD,
FIVE_MAN_THRESHOLD,
} from "@/server/contexts/report/domain/models/transaction-utils";
import {
Expand Down Expand Up @@ -125,7 +124,7 @@ export interface ExpenseRow {
*/
export interface UtilityExpenseSection {
totalAmount: number;
underThresholdAmount: number; // その他の支出(10万円未満
underThresholdAmount: number; // その他の支出(5万円未満
rows: ExpenseRow[];
}

Expand All @@ -134,7 +133,7 @@ export interface UtilityExpenseSection {
*/
export interface SuppliesExpenseSection {
totalAmount: number;
underThresholdAmount: number; // その他の支出(10万円未満
underThresholdAmount: number; // その他の支出(5万円未満
rows: ExpenseRow[];
}

Expand All @@ -143,7 +142,7 @@ export interface SuppliesExpenseSection {
*/
export interface OfficeExpenseSection {
totalAmount: number;
underThresholdAmount: number; // その他の支出(10万円未満
underThresholdAmount: number; // その他の支出(5万円未満
rows: ExpenseRow[];
}

Expand Down Expand Up @@ -295,10 +294,10 @@ const ExpenseTransactionBase = {
},

/**
* 閾値(10万円)以上かどうかを判定
* 閾値(5万円)以上かどうかを判定
*/
isAboveThreshold: (tx: BaseExpenseTransaction): boolean => {
return isAboveThreshold(ExpenseTransactionBase.resolveAmount(tx), TEN_MAN_THRESHOLD);
return isAboveThreshold(ExpenseTransactionBase.resolveAmount(tx), FIVE_MAN_THRESHOLD);
},

/**
Expand Down Expand Up @@ -493,8 +492,8 @@ export const UtilityExpenseSection = {
* トランザクションリストからセクションを構築する
*
* Business rules:
* - Transactions >= 100,000 yen are listed individually
* - Transactions < 100,000 yen are aggregated into underThresholdAmount
* - Transactions >= 50,000 yen are listed individually
* - Transactions < 50,000 yen are aggregated into underThresholdAmount
*/
fromTransactions: (transactions: UtilityExpenseTransaction[]): UtilityExpenseSection => {
return aggregateExpenseSection(transactions);
Expand Down Expand Up @@ -523,8 +522,8 @@ export const SuppliesExpenseSection = {
* トランザクションリストからセクションを構築する
*
* Business rules:
* - Transactions >= 100,000 yen are listed individually
* - Transactions < 100,000 yen are aggregated into underThresholdAmount
* - Transactions >= 50,000 yen are listed individually
* - Transactions < 50,000 yen are aggregated into underThresholdAmount
*/
fromTransactions: (transactions: SuppliesExpenseTransaction[]): SuppliesExpenseSection => {
return aggregateExpenseSection(transactions);
Expand Down Expand Up @@ -553,8 +552,8 @@ export const OfficeExpenseSection = {
* トランザクションリストからセクションを構築する
*
* Business rules:
* - Transactions >= 100,000 yen are listed individually
* - Transactions < 100,000 yen are aggregated into underThresholdAmount
* - Transactions >= 50,000 yen are listed individually
* - Transactions < 50,000 yen are aggregated into underThresholdAmount
*/
fromTransactions: (transactions: OfficeExpenseTransaction[]): OfficeExpenseSection => {
return aggregateExpenseSection(transactions);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function sanitizeText(value: string | null | undefined, maxLength?: numbe
}

/**
* 10万円の閾値(政治資金報告書における明細記載基準:経常経費用
* 10万円の閾値(政治資金報告書における明細記載基準:その他の収入用
*/
export const TEN_MAN_THRESHOLD = 100_000;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface TransactionWithCounterpart {
name: string;
address: string | null;
} | null;
/** 取引先情報の記載が必要(閾値以上かつ対象カテゴリ)かどうか。閾値は経常経費10万円、政治活動費5万円 */
/** 取引先情報の記載が必要(閾値以上かつ対象カテゴリ)かどうか。閾値は経常経費5万円、政治活動費5万円 */
requiresCounterpart: boolean;
/** 交付金に係る支出かどうか */
isGrantExpenditure: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -752,10 +752,10 @@ export class PrismaReportTransactionRepository implements IReportTransactionRepo
}

if (requiresCounterpartOnly) {
// 経常経費は10万円以上、政治活動費は5万円以上、収入は閾値なし(すべて記載)
// 経常経費は5万円以上、政治活動費は5万円以上、収入は閾値なし(すべて記載)
conditions.push({
OR: [
// 経常経費: 10万円以上
// 経常経費: 5万円以上
{
categoryKey: { in: [...ROUTINE_EXPENSE_CATEGORIES] },
debitAmount: { gte: ROUTINE_EXPENSE_THRESHOLD },
Expand Down
Loading
Loading