Skip to content

Annual Leave Promotion — Individual Retry and Sign Ticket Error Repair Plan ​

Status: implemented; local migration and one-row QA data repair pending Date: 2026-08-06 Scope: individual first-notice failure and retry only Approval rule: before changing code or local test data, the executing Agent must obtain Eron's explicit approval. Commit and push require separate approval.

Follow-up blocker: Sign automatic send currently fails with -117 INVALID_CONTRACT_BODY and leaves orphan drafts. Continue with 2026-08-06-annual-leave-promotion-sign-invalid-body-handoff.md before further manual retry.

1. Problem ​

An individual annual-leave promotion first notice failed while Hereby Sign returned:

text
Hereby error (/contract/external/create-and-send-from-template): code=-152

Sign defines -152 as NOT_ENOUGH_TICKET. DHub correctly persisted the operation as:

  • business status: not_notified;
  • processing status: retryable_failure;
  • Sign attempt status: failed;
  • e-sign status: error;
  • original firstNoticeOperationKey and externalRequestKey: preserved.

When the administrator clicks 개별 통지 again, the admin frontend generates a new UUID and submits a different operation key. The API rejects it with:

text
실패한 안내는 같은 요청으로 재시도해 주세요.

This rejection is correct. The UI is wrong: it offers a new-send action for a retryable failed request and does not provide an individual retry action that preserves the original idempotency boundary.

2. Root Cause ​

LayerCurrent behaviorDefect
SignChecks one common contract ticket before finalEdit; returns -152 when none is availableExpected billing behavior
DHub Sign clientMaps unknown Sign codes to a generic Hereby error ... code=-152User-facing message is not actionable
DHub APIRequires a retry to use the original firstNoticeOperationKeyCorrect idempotency protection
Admin row actionLooks only at businessStatus; not_notified + retryable_failure is rendered as 개별 통지Retryable failure is not represented
Admin submitAlways generates annual-leave-promotion:first:{employeeId}:{randomUUID}A retry is sent as a new request
Individual request snapshotTemplate ID and duration are stored, but template fingerprint is not stored on ESignRecordThe server cannot fully reconstruct the immutable original request without trusting the browser

The core problem is not the backend conflict message. A retryable operation must be resumed from server-owned snapshots with the same idempotency key; the browser must not manufacture retry identity.

3. Repair Principles ​

  1. Add an explicit server-owned individual retry API that accepts only promotionId.
  2. The server reconstructs the retry from the original promotion, attempt, and e-sign snapshots.
  3. Reuse the original firstNoticeOperationKey / externalRequestKey.
  4. Reuse the same employee, period, template ID, template fingerprint, and response duration.
  5. Reuse the same AnnualLeavePromotion, AnnualLeavePromotionSignAttempt, and ESignRecord; do not create a second logical attempt for a transport/payment failure before publication.
  6. Never delete the failed record to make the button work.
  7. Never bypass Sign ticket validation in production or development code.
  8. If remote success is uncertain or a contract/reference/message already exists, do not retry blindly; move to or preserve review_required.
  9. Show -152 as a Korean, actionable error while keeping the numeric code in structured logs.
  10. Local QA ticket provisioning is test-data setup, not product logic.

4. In Scope ​

  • Add a dedicated individual first-notice retry schema, SDK method, router mutation, and service method.
  • Persist the authoritative Sign template fingerprint for new individual first-notice e-sign records.
  • Return a server-derived canRetryFirstNotice flag in the promotion list.
  • Render 재시도 for retryable individual failures.
  • Retry through the dedicated endpoint without reopening editable template/deadline controls.
  • Map Sign -152 to an actionable Korean TRPCError.
  • Add unit, API/service, frontend, and manual QA coverage.
  • Document local Sign ticket setup as a prerequisite for successful QA.

5. Out of Scope ​

  • Bypassing or removing Sign ticket consumption.
  • Automatically purchasing or granting production tickets.
  • Changing batch retry behavior.
  • Changing reminder, expired reissue, or second-designation behavior.
  • Reusing a failed request with a different employee, period, template, or duration.
  • Deleting audit records, failed attempts, e-sign records, or remote draft contracts.
  • Compatibility fallback for old rows that have no trustworthy template fingerprint snapshot.

6. Target State ​

text
First notice
  -> Sign returns -152
  -> DHub stores retryable_failure with original request identity
  -> UI shows 재시도
  -> Admin confirms retry
  -> API loads original server snapshots
  -> API calls Sign with the same request key
  -> Success: publish one message and move to awaiting_signature
  -> Failure: remain retryable_failure with updated error/audit evidence

The administrator cannot change the template or response duration during a retry. Changing request content is a new business operation, not a retry.

7. Data Model ​

7.1 Add the missing individual template snapshot ​

Modify packages/database/src/entities/e-sign-record.entity.ts:

ts
@Column({ type: "varchar", length: 128, nullable: true })
templateFingerprintSnapshot?: string | null;

Add migration:

text
packages/database/src/migrations/1878000000000-AddESignTemplateFingerprintSnapshot.ts

Migration behavior:

  • add templateFingerprintSnapshot varchar(128) NULL;
  • do not fabricate or infer fingerprints for historical records;
  • down migration drops only this column.

The column is nullable because e_sign_records also contains non-promotion and historical records. For every new annual-leave promotion first notice, the service must require the authoritative fingerprint returned by listTemplates and save it before the remote create/send call.

7.2 Existing local failed QA row ​

The current local failure predates this snapshot. Do not add a compatibility fallback that silently uses the current template version.

For local QA only, after the repair is implemented and before retesting:

  1. grant the Holder a valid local test ticket through approved test-data setup;
  2. run migration 1878000000000-AddESignTemplateFingerprintSnapshot;
  3. read the exact original fingerprint from Sign's dhub_external_contract_requests row by the preserved request key;
  4. backfill only the matching local QA e_sign_records row using an explicit, reviewed development-data operation;
  5. retry the existing first notice.

Read-only verification on 2026-08-06 confirmed that the one local retryable failure has a matching Sign request in preparing state, has no remote contract ID, and its original fingerprint still matches the current template. Therefore this exact one-row backfill is safer than deleting the audit trail or creating a new request.

This cleanup must never become an application endpoint or production migration.

8. Backend API ​

8.1 Core schema ​

Modify packages/core/src/schemas/annual-leave-promotion.schema.ts:

ts
export const retryFirstNoticeSchema = z.object({
  promotionId: uuidSchema,
});

Export the corresponding input type through the existing schema export chain.

8.2 Router ​

Modify packages/api/src/routers/annual-leave-promotion.router.ts and add:

text
annualLeavePromotion.retryFirstNotice

Rules:

  • use the same scoped promotion permission as firstNotice;
  • load the promotion by organizationId + promotionId + deletedAt IS NULL;
  • call assertInScope with the server-loaded employee ID;
  • accept no operation key, employee ID, template ID, fingerprint, period, or duration from the browser.

8.3 Retry service ​

Add retryFirstNotice beside sendFirstNotice in packages/api/src/services/annual-leave-promotion.service.ts.

It must load, in the same tenant:

  • AnnualLeavePromotion;
  • its currentSignAttemptId;
  • AnnualLeavePromotionSignAttempt;
  • related ESignRecord.

Required preconditions:

CheckRequired value
Promotion processing statusretryable_failure
Promotion business statusnot_notified
Attempt statusfailed
E-sign statuserror
Message publicationno published message / no messageId
Request identityfirstNoticeOperationKey === externalRequestKey and both present
Contract evidenceno recipientReference, no published deadline, no successful send timestamp
Retry snapshotstemplate ID, fingerprint, response duration, employee, and period all present

If remote-success evidence is contradictory or uncertain, return a Korean conflict error and transition to review_required where valid. Do not create or send another request.

After validation, call the existing sendFirstNotice using only server-loaded values:

text
employeeId                 <- promotion.employeeId
periodKey/start/end        <- promotion snapshots
herebyTemplateId           <- eSignRecord.herebyTemplateId
templateFingerprint        <- eSignRecord.templateFingerprintSnapshot
responseDurationSeconds    <- attempt/e-sign snapshot (must agree)
operationKey               <- promotion.firstNoticeOperationKey
actorId                    <- current authenticated actor

The retry must preserve the original target/schedule/template snapshots. The current actor is recorded as the retry executor in a new audit event, but does not replace the original request identity.

8.4 Retry audit event ​

Append an event before or as part of the claimed retry transition:

text
first_notice_retry_requested

Payload should contain only non-secret evidence:

  • attempt ID;
  • e-sign record ID;
  • previous error category/code;
  • retry actor ID;
  • retry timestamp.

Never store API secrets, bearer URLs, or raw credentials.

9. Sign Error Mapping ​

Modify packages/api/src/services/hereby-contract.service.ts:

ts
case -152:
  return new TRPCError({
    code: "PRECONDITION_FAILED",
    message: "Sign 서명 이용권이 부족해요. 이용권을 충전한 뒤 같은 요청으로 재시도해 주세요.",
  });

Requirements:

  • UI receives Korean 해요체 text;
  • structured server logs retain endpoint and remote code -152;
  • credentials and request bodies are not logged;
  • other unknown codes keep the existing generic handling.

10. Promotion List Contract ​

Modify the list response in packages/api/src/routers/annual-leave-promotion.router.ts to return:

ts
canRetryFirstNotice: boolean

Compute it on the server from the relevant statuses and snapshot availability. Do not make the browser reproduce the complete state machine.

The browser does not need the raw firstNoticeOperationKey to retry. Keeping retry identity server-owned prevents accidental mutation and duplicate contracts.

11. SDK ​

Modify packages/sdk/src/services/AnnualLeavePromotionService.ts:

ts
async retryFirstNotice(promotionId: string) {
  return this.client.trpc.annualLeavePromotion.retryFirstNotice.mutate({ promotionId });
}

Do not add optional legacy payload fields or client-generated operation keys.

12. Admin UI ​

12.1 Row action ​

Modify apps/admin/src/views/tabs/leave-management.html.

Action precedence must be explicit:

  1. canRetryFirstNotice -> 재시도;
  2. awaiting_signature -> 재안내;
  3. expired_cancelled -> 다시 발송;
  4. applied -> 완료 내역;
  5. otherwise -> 개별 통지.

The retry check must precede the business-status checks because the failed operation remains not_notified.

12.2 Retry interaction ​

Modify apps/admin/src/app.ts and add:

ts
retryAnnualLeavePromotion(promotion: Record<string, unknown>): Promise<void>

Behavior:

  1. require promotion.id and canRetryFirstNotice;

  2. show askConfirm with:

    text
    실패한 통지를 같은 요청으로 다시 시도할까요?
  3. disable repeated clicks while the mutation is pending;

  4. call sdk.annualLeavePromotion.retryFirstNotice({ promotionId });

  5. refresh annual-leave balances/promotions;

  6. show the API's actionable Korean error on failure.

Do not reopen the ordinary first-notice modal for retry. That modal permits template/deadline selection and therefore represents a new request, not a retry.

12.3 Existing first-notice submission ​

Keep UUID generation for genuinely new individual first notices. The dedicated retry action must never call that new-send branch.

13. Local QA Ticket Prerequisite ​

The currently bound local Sign Holder is the master user of Hereby Demo Corporation and has zero active common-contract tickets. A successful retry is impossible until local test entitlement exists.

Use an approved local seed/setup path to grant enough common tickets for the planned test count. Batch tests require at least one ticket per successful employee send.

Do not:

  • bypass PaymentHttpService.checkTickets;
  • hardcode free sends for DHub;
  • change production plans;
  • directly mutate a shared or non-local database.

14. Tests ​

14.1 Sign client error mapping ​

Add or update tests for packages/api/src/services/hereby-contract.service.ts:

  1. remote -152 becomes PRECONDITION_FAILED;
  2. message is the fixed Korean copy;
  3. unknown errors remain generic;
  4. credentials are not included in error text.

14.2 Retry service tests ​

Add focused tests for:

  1. valid failed request reuses the original operation key;
  2. valid retry reuses the same promotion, attempt, and e-sign IDs;
  3. employee/template/period/duration/fingerprint come from server snapshots;
  4. missing fingerprint denies retry;
  5. different promotion/e-sign request keys deny retry;
  6. non-retryable_failure promotion denies retry;
  7. non-failed attempt denies retry;
  8. published message denies retry;
  9. existing recipient reference or contradictory remote-success evidence denies blind retry;
  10. cross-organization and out-of-scope access are denied;
  11. double-click/concurrent retry remains idempotent;
  12. repeated -152 leaves one retryable workflow and updates error/audit evidence;
  13. successful retry transitions to awaiting_signature and publishes exactly one message.

14.3 Frontend tests ​

Add tests proving:

  1. not_notified + canRetryFirstNotice renders 재시도;
  2. retry has precedence over 개별 통지;
  3. retry calls only { promotionId };
  4. retry never generates a UUID;
  5. retry does not open the editable first-notice modal;
  6. retry refreshes the list after success;
  7. pending state prevents a duplicate click;
  8. Sign ticket shortage displays the Korean actionable message.

14.4 Migration test ​

Verify migration up/down and entity metadata for templateFingerprintSnapshot.

15. Manual QA ​

Failure and retry ​

  1. Use a local Holder with zero tickets.
  2. Send one individual first notice.
  3. Confirm the UI displays the Korean ticket-shortage message.
  4. Confirm the row becomes 재시도, not 개별 통지.
  5. Confirm one promotion, one attempt, and one e-sign record exist.
  6. Grant one local common-contract test ticket through the approved setup path.
  7. Click 재시도 and confirm the retry prompt.
  8. Confirm the same operation key and local record IDs are reused.
  9. Confirm exactly one Sign request is successfully sent.
  10. Confirm exactly one Messenger workflow message is published.
  11. Confirm status becomes 1차 안내됨 · 서명 대기.
  12. Confirm the one ticket is consumed once.

Negative cases ​

  1. Double-click retry -> one remote request and one message.
  2. Refresh before retry -> action remains 재시도.
  3. Change the Sign template after failure -> retry fails safely; no new contract.
  4. Remove organization scope -> retry is denied.
  5. Retry an awaiting/completed/expired request -> denied with the correct Korean message.

16. Verification Commands ​

Run targeted tests first, then package builds, then repository verification:

bash
pnpm --filter @hereby/api test
pnpm --filter @hereby/sdk build
pnpm --filter @hereby/admin test
pnpm --filter @hereby/api build
pnpm --filter @hereby/admin build
pnpm lint:check
pnpm build

Run the exact focused Vitest files once their filenames are chosen; do not rely only on broad package scripts.

17. Acceptance Criteria ​

  • [x] Sign -152 is shown as an actionable Korean message.
  • [x] An individual retryable failure renders 재시도.
  • [x] The retry API accepts only promotionId from the browser.
  • [x] Retry identity and request snapshots are loaded by the server.
  • [x] The same operation key, promotion, attempt, and e-sign record are reused.
  • [x] Concurrent retry is atomically claimed before the remote call.
  • [x] Contradictory/uncertain remote state is not retried blindly.
  • [x] New individual requests persist the template fingerprint snapshot.
  • [x] Batch retry, reminder, reissue, and second designation are unchanged.
  • [ ] Local migration and the verified one-row QA fingerprint backfill are applied.
  • [ ] Manual QA confirms one Sign contract and one Messenger message.
  • [x] Focused tests, affected package builds, and full repository build pass.
  • [ ] Full repository lint passes; unrelated existing employee-app CSS and maintenance-script diagnostics still block it.

18. Files Expected to Change During Implementation ​

Add ​

  • packages/database/src/migrations/1878000000000-AddESignTemplateFingerprintSnapshot.ts
  • focused API/service/frontend test files as required by existing test organization

Modify ​

  • packages/database/src/entities/e-sign-record.entity.ts
  • packages/core/src/schemas/annual-leave-promotion.schema.ts
  • packages/api/src/services/hereby-contract.service.ts
  • packages/api/src/services/annual-leave-promotion.service.ts
  • packages/api/src/routers/annual-leave-promotion.router.ts
  • packages/sdk/src/services/AnnualLeavePromotionService.ts
  • apps/admin/src/app.ts
  • apps/admin/src/views/tabs/leave-management.html

Do not modify Hereby Sign ticket validation for this repair.