3-step wizard for finalizing loan terms and payment setup
{
"loan_amount": 150000,
"term_months": 36,
"apr": 4.5,
"monthly_payment": 4440,
"primary_purpose": "expansion",
"specific_use": "Opening second café location in Dalston"
}
Logic: If today is 23 Nov and customer selects "1st of month", first payment = 1 Dec 2025
Grace Period: Minimum 7 days between loan finalization and first payment
Example: Finalized on 25 Nov → First payment can't be 1 Dec (only 6 days), so pushed to 1 Jan 2026
Single finalization endpoint to lock in loan offer
POST /api/v1/applications/APP-2025-001234/finalize-offer Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... Content-Type: application/json { "loan_details": { "amount": 150000, "term_months": 36, "monthly_payment": 4440.12, "apr": 4.5, "primary_purpose": "expansion", "specific_use": "Opening second café location in Dalston" }, "payment_setup": { "payment_day": 1, // 1st of month "account_type": "existing", "account_id": "ACC-1234", "first_payment_date": "2025-12-01" }, "special_notes": "", "finalized_at": "2025-11-23T14:45:00Z" }
{
"request_id": "req_cm07_20251123_144500",
"status": "success",
"application_id": "APP-2025-001234",
"offer_status": "finalized",
"offer_reference": "OFFER-2025-001234",
"next_step": {
"screen": "CM08",
"action": "document_upload",
"message": "Please upload supporting documents"
},
"timeline": {
"offer_finalized": "2025-11-23T14:45:00Z",
"first_payment": "2025-12-01",
"estimated_disbursement": "2025-11-27" // After docs + signature
},
"processing_time_ms": 324
}
POST /api/v1/applications/APP-2025-001234/save-draft { "current_step": 2, "draft_data": { "loan_details": { /* from step 1 */ }, "payment_setup": { /* partial from step 2 */ } }, "saved_at": "2025-11-23T14:40:00Z" }
Validation rules and first payment calculation
function validateFinalization(application, request) { // Check amount matches CM06 if (request.loan_details.amount !== application.chosen_amount) { throw new ValidationError("Amount mismatch"); } // Check APR not expired const daysSinceAssessment = daysBetween( application.credit_assessed_at, new Date() ); if (daysSinceAssessment > 7) { throw new RateExpiredError("Rate expired, re-run CM05"); } // Check first payment date valid const daysUntilFirstPayment = daysBetween( new Date(), request.payment_setup.first_payment_date ); if (daysUntilFirstPayment < 7) { throw new ValidationError("First payment too soon"); } return true; }
Automatically calculate first payment date based on customer's selected payment day and 7-day grace period:
function calculateFirstPaymentDate(selectedDay, finalizationDate) { // Start with next occurrence of selected day let firstPayment = nextOccurrenceOf(selectedDay, finalizationDate); // Ensure 7-day minimum gap const minDate = addDays(finalizationDate, 7); if (firstPayment < minDate) { // Push to next month if too soon firstPayment = nextOccurrenceOf(selectedDay, minDate); } return firstPayment; } // Example: // Finalized: 23 Nov 2025, Selected Day: 1st // Next 1st: 1 Dec 2025 (8 days away) ✓ Valid // Result: 2025-12-01 // Counter-example: // Finalized: 25 Nov 2025, Selected Day: 1st // Next 1st: 1 Dec 2025 (6 days away) ✗ Too soon // Result: Push to 1 Jan 2026 (37 days away)
async function finalizeOffer(applicationId, offerData) { // Check current status const app = await Application.findById(applicationId); if (app.status !== "scenario_selected") { throw new WorkflowError("Invalid state for finalization"); } // Validate and finalize validateFinalization(app, offerData); // Update application app.status = "offer_finalized"; app.offer_reference = generateOfferReference(); app.offer_finalized_at = new Date(); app.primary_purpose = offerData.loan_details.primary_purpose; app.specific_use = offerData.loan_details.specific_use; app.payment_day = offerData.payment_setup.payment_day; app.first_payment_date = offerData.payment_setup.first_payment_date; await app.save(); return app; }
Generate unique offer reference for tracking and audit:
function generateOfferReference() { const year = new Date().getFullYear(); const sequentialId = getNextSequenceNumber(); return `OFFER-${year}-${sequentialId.toString().padStart(6, '0')}`; } // Example: OFFER-2025-001234
API Gateway, caching, and notification triggers
{
"event_type": "offer_finalized",
"application_id": "APP-2025-001234",
"offer_reference": "OFFER-2025-001234",
"customer": {
"email": "olivia@smithscafe.com",
"phone": "+44 7700 900123"
},
"loan_details": {
"amount": 150000,
"term": 36,
"monthly_payment": 4440
},
"next_action": "Upload supporting documents",
"timestamp": "2025-11-23T14:45:00Z"
}
analytics.track('Offer Finalized', { application_id: 'APP-2025-001234', time_on_screen: 87, // seconds steps_completed: 3, payment_day_selected: 1, used_existing_account: true, purpose: 'expansion' });
No external dependencies - fully internal process
We intentionally delay loan account creation until after CM10 (final confirmation) because:
Database updates and audit logging
Update main applications table with finalized offer details:
UPDATE applications SET current_status = 'offer_finalized', offer_reference = 'OFFER-2025-001234', offer_finalized_at = CURRENT_TIMESTAMP, primary_purpose = 'expansion', specific_use = 'Opening second café location in Dalston', payment_day = 1, first_payment_date = '2025-12-01', direct_debit_account_id = 'ACC-1234', special_notes = '', updated_at = CURRENT_TIMESTAMP WHERE id = 'APP-2025-001234' AND current_status = 'scenario_selected'; -- Workflow guard
Log offer finalization event for compliance:
{
"event_type": "offer_finalized",
"application_id": "APP-2025-001234",
"offer_reference": "OFFER-2025-001234",
"customer_id": "CUST-001234",
"timestamp": "2025-11-23T14:45:00Z",
"actor": {
"type": "customer",
"id": "CUST-001234",
"name": "Olivia Thompson"
},
"offer_details": {
"amount": 150000,
"term_months": 36,
"apr": 4.5,
"monthly_payment": 4440.12,
"primary_purpose": "expansion",
"specific_use": "Opening second café location in Dalston"
},
"payment_setup": {
"payment_day": 1,
"first_payment_date": "2025-12-01",
"account_id": "ACC-1234"
},
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)",
"ip_address": "86.134.x.x"
}
If customer clicks "Save & continue later", store draft in separate table:
| Column | Type | Description | Example |
|---|---|---|---|
id |
UUID | Primary key | 7a3e8900-e29b... |
application_id |
VARCHAR(50) | Foreign key | APP-2025-001234 |
current_step |
INTEGER | Step number (1-3) | 2 |
draft_data |
JSONB | Partial form data | {"loan_details": {...}} |
saved_at |
TIMESTAMP | When draft saved | 2025-11-23 14:40:00 |
expires_at |
TIMESTAMP | Draft expiry (24hr) | 2025-11-24 14:40:00 |
SELECT id, offer_reference, credit_assessed_at, EXTRACT(DAY FROM