読み込み中
豆知識
For every posting operation (journal entries, invoice posting, bill posting, expense claims), compute:
idempotencyKey = sha256(
companyId +
"|" +
sourceDocumentType +
"|" +
sourceDocumentId +
"|" +
(version || 0) +
"|" +
serviceDate +
"|" +
total
)
import crypto from "crypto"
interface PostingOperation {
companyId: string
sourceDocumentType:
| "sales_invoice"
| "purchase_bill"
| "expense_claim"
| "adjustment"
| "opening_balance"
sourceDocumentId: string
version?: number
serviceDate: string // LocalDate format: YYYY-MM-DD
total: number // Integer JPY amount
}
function generateIdempotencyKey(operation: PostingOperation): string {
const keyComponents = [
operation.companyId,
operation.sourceDocumentType,
operation.sourceDocumentId,
(operation.version || 0).toString(),
operation.serviceDate,
operation.total.toString(),
]
const keyString = keyComponents.join("|")
return crypto.createHash("sha256").update(keyString).digest("hex")
}
const invoice = {
id: "inv-2025-001",
companyId: "company-uuid",
serviceDate: "2025-01-29",
total: 110000,
version: 1,
}
const idempotencyKey = generateIdempotencyKey({
companyId: invoice.companyId,
sourceDocumentType: "sales_invoice",
sourceDocumentId: invoice.id,
version: invoice.version,
serviceDate: invoice.serviceDate,
total: invoice.total,
})
// Result: "a1b2c3d4e5f6..." (SHA-256 hash)
const bill = {
id: "bill-2025-001",
companyId: "company-uuid",
serviceDate: "2025-01-29",
total: 55000,
}
const idempotencyKey = generateIdempotencyKey({
companyId: bill.companyId,
sourceDocumentType: "purchase_bill",
sourceDocumentId: bill.id,
serviceDate: bill.serviceDate,
total: bill.total,
})
const openingBalance = {
id: "ob-2025",
companyId: "company-uuid",
serviceDate: "2025-01-01",
total: 5000000, // Sum of all OB lines
}
const idempotencyKey = generateIdempotencyKey({
companyId: openingBalance.companyId,
sourceDocumentType: "opening_balance",
sourceDocumentId: openingBalance.id,
serviceDate: openingBalance.serviceDate,
total: openingBalance.total,
})
interface IdempotentRequest extends Request {
idempotencyKey?: string
isRetry?: boolean
}
function idempotencyMiddleware() {
return async (req: IdempotentRequest, res: Response, next: NextFunction) => {
// Only apply to posting operations
if (!req.path.includes("/post") && req.method !== "POST") {
return next()
}
// Generate idempotency key from request body
try {
const operation: PostingOperation = {
companyId: req.body.companyId,
sourceDocumentType: req.body.sourceDocumentType,
sourceDocumentId: req.body.sourceDocumentId,
version: req.body.version,
serviceDate: req.body.serviceDate,
total: req.body.total,
}
req.idempotencyKey = generateIdempotencyKey(operation)
// Check if this operation was already processed
const existingEntry = await JournalEntry.findOne({
where: { idempotencyKey: req.idempotencyKey },
})
if (existingEntry) {
// Return the original result (safe replay)
req.isRetry = true
return res.status(200).json({
message: "Operation already processed",
journalEntryId: existingEntry.id,
idempotencyKey: req.idempotencyKey,
originalTimestamp: existingEntry.createdAt,
isRetry: true,
})
}
next()
} catch (error) {
res.status(400).json({
error: {
code: "IDEMPOTENCY_KEY_GENERATION_FAILED",
message: "Failed to generate idempotency key",
details: { error: error.message },
},
})
}
}
}
-- In journal_entry table
ALTER TABLE journal_entry
ADD CONSTRAINT uk_idempotency_key
UNIQUE (idempotency_key);
-- Handle constraint violations
CREATE OR REPLACE FUNCTION handle_idempotency_violation()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
-- Check if this is a duplicate idempotency key
IF EXISTS (SELECT 1 FROM journal_entry WHERE idempotency_key = NEW.idempotency_key) THEN
RAISE EXCEPTION 'DUPLICATE_IDEMPOTENCY_KEY: Operation already processed'
USING ERRCODE = 'unique_violation';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_idempotency_check
BEFORE INSERT ON journal_entry
FOR EACH ROW EXECUTE FUNCTION handle_idempotency_violation();
interface VersionedDocument {
id: string
version: number
lastModified: string
}
function postDocumentVersion(
document: VersionedDocument,
operation: PostingOperation
) {
// Include version in idempotency key
const keyWithVersion = generateIdempotencyKey({
...operation,
version: document.version,
})
// If document is modified, version changes, creating new idempotency key
// This allows re-posting modified documents while preventing duplicates
return keyWithVersion
}
async function postInvoice(invoice: Invoice) {
// Get current version
const currentInvoice = await Invoice.findById(invoice.id)
// If invoice was modified since last post, increment version
if (currentInvoice.lastModified !== invoice.lastModified) {
invoice.version = (currentInvoice.version || 0) + 1
}
const idempotencyKey = generateIdempotencyKey({
companyId: invoice.companyId,
sourceDocumentType: "sales_invoice",
sourceDocumentId: invoice.id,
version: invoice.version,
serviceDate: invoice.serviceDate,
total: invoice.total,
})
// Post with version-specific idempotency key
return await postJournalEntry({ ...invoice, idempotencyKey })
}
app.post(
"/api/journal-entries/post",
idempotencyMiddleware(),
async (req, res) => {
try {
if (req.isRetry) {
// Already handled by middleware
return
}
const journalEntry = await createJournalEntry({
...req.body,
idempotencyKey: req.idempotencyKey,
})
res.status(201).json({
journalEntryId: journalEntry.id,
idempotencyKey: req.idempotencyKey,
status: "created",
})
} catch (error) {
if (error.code === "DUPLICATE_IDEMPOTENCY_KEY") {
// Find and return the original entry
const originalEntry = await JournalEntry.findOne({
where: { idempotencyKey: req.idempotencyKey },
})
res.status(409).json({
error: {
code: "DUPLICATE_OPERATION",
message: "Operation already processed",
details: {
originalJournalEntryId: originalEntry.id,
originalTimestamp: originalEntry.createdAt,
idempotencyKey: req.idempotencyKey,
},
},
})
} else {
throw error
}
}
}
)
interface PostingRequest {
companyId: string
sourceDocumentType: string
sourceDocumentId: string
version?: number
serviceDate: string
total: number
}
async function postWithIdempotency(
request: PostingRequest,
maxRetries: number = 3
): Promise<{ journalEntryId: string; isRetry: boolean }> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch("/api/journal-entries/post", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
})
if (response.ok) {
const result = await response.json()
return {
journalEntryId: result.journalEntryId,
isRetry: result.isRetry || false,
}
}
if (response.status === 409) {
// Conflict - operation already processed
const error = await response.json()
return {
journalEntryId: error.error.details.originalJournalEntryId,
isRetry: true,
}
}
if (response.status >= 500 && attempt < maxRetries) {
// Server error - retry
await delay(Math.pow(2, attempt) * 1000) // Exponential backoff
continue
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
} catch (error) {
if (attempt === maxRetries) {
throw error
}
// Network error - retry
await delay(Math.pow(2, attempt) * 1000)
}
}
throw new Error("Max retries exceeded")
}
describe("Idempotency Key Generation", () => {
it("generates consistent keys for identical operations", () => {
const operation = {
companyId: "company-1",
sourceDocumentType: "sales_invoice" as const,
sourceDocumentId: "inv-001",
version: 1,
serviceDate: "2025-01-29",
total: 110000,
}
const key1 = generateIdempotencyKey(operation)
const key2 = generateIdempotencyKey(operation)
expect(key1).toBe(key2)
expect(key1).toMatch(/^[a-f0-9]{64}$/) // SHA-256 hex
})
it("generates different keys for different versions", () => {
const baseOperation = {
companyId: "company-1",
sourceDocumentType: "sales_invoice" as const,
sourceDocumentId: "inv-001",
serviceDate: "2025-01-29",
total: 110000,
}
const key1 = generateIdempotencyKey({ ...baseOperation, version: 1 })
const key2 = generateIdempotencyKey({ ...baseOperation, version: 2 })
expect(key1).not.toBe(key2)
})
it("generates different keys for different amounts", () => {
const baseOperation = {
companyId: "company-1",
sourceDocumentType: "sales_invoice" as const,
sourceDocumentId: "inv-001",
serviceDate: "2025-01-29",
}
const key1 = generateIdempotencyKey({ ...baseOperation, total: 110000 })
const key2 = generateIdempotencyKey({ ...baseOperation, total: 110001 })
expect(key1).not.toBe(key2)
})
})
describe("Idempotency Integration", () => {
it("prevents duplicate posting of same invoice", async () => {
const invoice = {
companyId: "company-1",
sourceDocumentType: "sales_invoice",
sourceDocumentId: "inv-001",
serviceDate: "2025-01-29",
total: 110000,
}
// First post should succeed
const response1 = await request(app)
.post("/api/journal-entries/post")
.send(invoice)
expect(response1.status).toBe(201)
// Second post should return existing entry
const response2 = await request(app)
.post("/api/journal-entries/post")
.send(invoice)
expect(response2.status).toBe(200)
expect(response2.body.isRetry).toBe(true)
expect(response2.body.journalEntryId).toBe(response1.body.journalEntryId)
})
})
idempotency_key column for fast lookups読み込み中
豆知識