diff --git a/images/rbm-tester-management-icon.jpg b/images/rbm-tester-management-icon.jpg new file mode 100644 index 0000000..6a69a62 Binary files /dev/null and b/images/rbm-tester-management-icon.jpg differ diff --git a/images/rightonq-landscape-glow-logo.png b/images/rightonq-landscape-glow-logo.png new file mode 100644 index 0000000..f8aef42 Binary files /dev/null and b/images/rightonq-landscape-glow-logo.png differ diff --git a/rcs-registration/RCS_ONBOARDING_MAIN_BUILD_PLAN.md b/rcs-registration/RCS_ONBOARDING_MAIN_BUILD_PLAN.md new file mode 100644 index 0000000..545b21e --- /dev/null +++ b/rcs-registration/RCS_ONBOARDING_MAIN_BUILD_PLAN.md @@ -0,0 +1,2210 @@ +# RightOnQ RCS Onboarding Main Build Plan + +Created: Thursday 14 May 2026 +Creator: RCS-Twilio-4 +Project: RightOnQ RCS client onboarding, registration, billing, and launch workflow +Repo: `/Users/macpro/rightonq-code.github.io` +Primary app file today: `/Users/macpro/rightonq-code.github.io/rcs-registration/index.html` + +## Purpose + +This is the durable product build document for the RightOnQ RCS onboarding system. + +Every successor agent working on the RCS onboarding/product flow should read and update this file as well as their own handover diary. The handover diary records agent-to-agent operational state. This file records the product plan, decisions, workflow, schemas, statuses, and implementation slices. + +Keep this file practical and current. When the plan changes, update it here so future agents are not forced to reconstruct the product direction from chat history. + +## Current Product View + +The RCS registration form is not a standalone public form. It is one screen inside a wider client onboarding system. + +The client is becoming a RightOnQ customer. The journey needs to cover: + +- commercial acceptance; +- payment setup; +- Twilio Trust Hub / client KYC readiness; +- Twilio subaccount/runtime setup; +- RCS registration data capture; +- RightOnQ internal checks; +- phone name/logo preview; +- client approval; +- review video preparation and approval; +- provider/carrier submission; +- approved/live status; +- Twilio usage monitoring; +- prepaid credit/top-up control; +- ongoing RightOnQ service. + +The product should feel smooth and clear for clients, but it must also protect RightOnQ from operational and financial risk. + +Expected early volume is low, likely a few clients per week rather than dozens per day. Therefore, the first build can use manual internal controls where sensible, as long as the source of truth is structured and the customer experience is calm and professional. + +## Current Preferred Commercial/Billing Direction + +### Revolut-First Preference + +Bugs prefers to use Revolut as much as practical because RightOnQ already banks with Revolut and keeping payment movement under one operational hood has advantages. + +The current preferred direction is: + +- Use Revolut-first for the pilot if sandbox testing confirms the flow. +- Treat Stripe Billing as the benchmark/fallback, not the automatic first choice. +- Use GoCardless later only if larger UK B2B clients strongly prefer Direct Debit. + +### Revolut Role + +Revolut should ideally handle: + +- initial onboarding checkout/payment; +- payment method saving where supported; +- first month subscription/payment; +- prepaid usage credit/deposit payment; +- merchant-initiated top-up orders/charges where supported; +- reporting and reconciliation exports; +- webhook events back into RightOnQ's source of truth. + +### RightOnQ-Owned Billing Logic + +RightOnQ must still own the service and credit-control logic. + +RightOnQ should maintain: + +- customer/application ledger; +- prepaid usage credit balance; +- usage deductions; +- auto top-up threshold; +- payment failure state; +- service pause/suspension rules; +- manual override; +- internal notes and audit trail. + +Do not rely on Revolut alone as the full SaaS billing brain until its sandbox and operational fit are proven. + +### Initial Commercial Model Under Discussion + +Starting package: + +- `Local Time Only` +- `£25/month` +- PAYG usage fees on top +- minimum starting usage credit/deposit, likely `£50` + +Likely first payment: + +- `£25` first month +- `£50` starting usage credit +- total `£75` + +Important risk rule: + +- No client should get live Twilio-backed usage with unlimited postpaid exposure. +- Pause/suspend sending if top-up fails or prepaid usage credit is exhausted. + +## Twilio Direction + +Use one Twilio subaccount per customer/tenant. + +Benefits: + +- usage separation; +- credentials separation; +- reporting clarity; +- smaller operational blast radius; +- cleaner future automation. + +Important caveat: + +- Twilio subaccounts do not make the customer financially responsible to Twilio. +- Subaccount usage is still billed to the parent Twilio account. +- RightOnQ carries the Twilio exposure unless the internal ledger/top-up controls protect it. + +RightOnQ should pull/query Twilio usage per subaccount and reconcile it against each client's prepaid balance and invoices/payments. + +### Trust Hub / Secondary Compliance Profile Direction + +Important discovery on Thursday 14 May 2026: + +- Twilio subaccounts and Trust Hub compliance profiles are related but separate resource graphs. +- Subaccounts are runtime/account containers under Twilio `Accounts`. +- Trust Hub stores KYC/compliance profiles under `trusthub.twilio.com/v1`. +- For a RightOnQ-managed client such as `ABC Ltd`, the expected model is: + - RightOnQ primary compliance profile remains the approved parent profile; + - each end-client gets its own Secondary Compliance Profile / Secondary Customer Profile; + - phone numbers and other channel resources are linked to that compliance profile by assignment resources. +- Treat this as a third onboarding track beside commercial/payment and RCS sender registration. + +Updated design assumption after Isa Bell/Twilio reply on Thursday 14 May 2026: + +- Build the intake data model around one required primary authorised representative. +- Support an optional second authorised representative as backup/future-proofing, because Twilio guidance is mixed across Console/API/ISV docs. +- Do not force customer-facing rep-2 capture in the first launch form unless Bugs explicitly chooses to. +- Each representative record, where collected, should support: + - first name; + - last name; + - business/work email; + - phone number; + - business title; + - job position. + +Do not collect date of birth, passport, driving licence, government ID, or proof-of-address documents in the launch intake unless Twilio's live flow explicitly requires it. Isa's reply says passport/government ID is not an always-required upfront UK business-bundle field; Twilio asks for extra identity evidence if it cannot digitally verify the representative or their association with the business. If that happens, route evidence through a Twilio-managed compliance step or a secure manual/admin process, not the current static form and Google Sheet path. + +The field-authority principle is: + +- when RCS and Trust Hub ask for overlapping data, RightOnQ should ask the stricter/more precise version once; +- the canonical RightOnQ answer then feeds both the RCS sender registration and Twilio Trust Hub/KYC workflow. + +Useful official references checked: + +- Twilio Secondary Compliance Profiles: `https://www.twilio.com/docs/trust-hub/profiles/secondary-compliance-profiles` +- Twilio Trust Hub overview: `https://www.twilio.com/docs/trust-hub` +- Twilio API: Create a Secondary Customer Profile: `https://www.twilio.com/docs/trust-hub/trusthub-rest-api/api-create-secondary-customer-profile` +- Twilio UK long-code KYC: `https://support.twilio.com/hc/en-us/articles/21038555454875-Know-Your-Customer-KYC-in-the-United-Kingdom` + +### Isa Bell Email - Answer Received + +Bugs emailed Isa Bell at Twilio on Thursday 14 May 2026 to confirm the build-critical KYC points. + +The email asked, in practical terms: + +- whether each UK limited-company client should have a Secondary Customer/Compliance Profile under RightOnQ's approved Primary Profile; +- whether the UK long-code Regulatory Compliance Bundle is separate from, or fed by, the Secondary Customer/Compliance Profile; +- what identity evidence is normally required for the authorised representative of a UK limited company; +- whether RightOnQ can complete or trigger any passport/driving-licence verification through Twilio/Persona without storing copies of personal ID; +- whether one or two authorised representatives are required; +- whether larger/well-established UK limited companies can rely more on Companies House/company records, or whether individual identity verification is always required; +- how UK long-code SMS fallback numbers should be assigned when the number sits inside a RightOnQ-controlled Twilio subaccount. + +Isa replied on Thursday 14 May 2026 with these build-impacting answers: + +- RightOnQ's ISV model is correct: + - RightOnQ keeps the approved Primary Compliance Profile on the parent account; + - each end-client UK limited company gets its own Secondary Compliance Profile when the brand/entity differs from RightOnQ; + - Twilio docs now use `Compliance Profile` where older docs may say `Customer Profile`. +- The UK long-code Regulatory Compliance Bundle is separate from the Secondary Compliance Profile: + - the data overlaps; + - one does not replace the other; + - UK long-code fallback numbers should be assigned to the RC Bundle representing the actual end business. +- Personal identity evidence is not a universal upfront intake requirement: + - baseline UK business-bundle fields are business details, address, registration data, and authorised rep contact details; + - government ID/passport is an exception path if Twilio cannot digitally verify the representative; + - do not make passport or driving licence a mandatory upfront intake field. +- For reps, use one required primary authorised representative plus an optional second backup rep. +- If avoiding ID storage is important, design exception handling so the end customer enters/uploads evidence directly into a Twilio-managed compliance step or another secure approved route, not by emailing/uploading documents into the static app or Sheet. + +Immediate build impact: + +- update docs and field map from `two reps likely required` to `one required, optional second`; +- keep the current customer-facing form free of ID upload fields; +- keep KYC evidence as exception-only; +- treat Secondary Compliance Profile and UK RC Bundle as two separate operational checklist/status lanes, even though they share data. + +### Spawned Agent Research - Twilio KYC Docs + +Bugs spawned research agents after Isa's reply and pasted the consolidated build impact on Thursday 14 May 2026. + +The research supports the current architecture: + +- RightOnQ keeps the approved parent Primary Compliance Profile. +- Each end-client company gets its own Secondary Compliance Profile when the brand/entity differs from RightOnQ. +- If UK long-code SMS fallback is used, RightOnQ should build a separate UK Regulatory Compliance Bundle for the end business, then assign the UK number to that approved bundle. + +Intake fields to plan around: + +- one required primary authorised representative: + - first name; + - last name; + - work email; + - mobile number; +- optional second authorised representative, because Twilio's Console/API guidance is mixed; +- UK business fields: + - legal company name; + - company registration number; + - website; + - address; + - business classification; + - subassignment flag; + - optional comments; +- Twilio status tracking: + - `draft`; + - `pending_review`; + - `in_review`; + - `twilio_approved`; + - `twilio_rejected`; + - rejection/error reasons. + +Identity evidence remains exception-only: + +- `18019`: Twilio could not verify the authorised representative's identity; government ID or passport may be requested. +- `18020`: Twilio needs proof the representative is associated with the business. +- `18057`: digital validation of the authorised representative failed; may need a different representative or an explanation of the company/website connection. + +Do not make passport or driving licence a normal upfront field. + +RightOnQ document-storage stance: + +- Use Twilio-managed compliance collection wherever available. +- Store Twilio IDs, statuses, and rejection reasons rather than raw ID documents. +- Do not promise universally that RightOnQ never touches evidence until Twilio confirms UK RC Bundle / Secondary Profile coverage for the relevant embeddable path. + +Remaining uncertainties from the research: + +- Whether UK RCS production onboarding consumes the same Trust Hub Secondary Compliance Profile cleanly, or adds separate carrier/RCS-specific checks. +- Whether RightOnQ's Twilio account has the required ISV/subaccount/embeddable capabilities enabled. +- Exact UK long-code purchase enforcement should be tested in the live account before final UX copy. + +Research references supplied by the agents: + +- Twilio Secondary Compliance Profiles: `https://www.twilio.com/docs/trust-hub/profiles/secondary-compliance-profiles` +- Twilio API: Create a Secondary Customer Profile: `https://www.twilio.com/docs/trust-hub/trusthub-rest-api/api-create-secondary-customer-profile` +- Twilio Reading Regulations for the UK Bundle: `https://www.twilio.com/docs/phone-numbers/regulatory/reading-regulations-for-the-uk-bundle` +- Twilio KYC in the United Kingdom: `https://help.twilio.com/articles/21038555454875-Know-Your-Customer-KYC-in-the-United-Kingdom` +- Twilio Regulatory Compliance REST APIs: `https://www.twilio.com/docs/phone-numbers/regulatory/api` +- Twilio Compliance Embeddable onboarding: `https://www.twilio.com/docs/messaging/compliance/toll-free/compliance-embeddable-onboarding` +- Twilio Voice Integrity ISV/subaccount flow: `https://www.twilio.com/docs/voice/spam-monitoring-with-voiceintegrity/voice-integrity-onboarding/voiceintegrity-onboarding-in-the-twilio-console` +- Twilio errors `18019`, `18020`, and `18057`. + +## Field Authority Map - Draft 1 + +Purpose: map each customer/intake field to the strictest downstream requirement so RightOnQ asks once, asks accurately, and does not store sensitive data in the wrong place. + +| Field area | Current app state | RCS sender registration | Trust Hub / KYC | UK RC Bundle / long-code | Storage sensitivity | Current action | +| --- | --- | --- | --- | --- | --- | --- | +| Legal business name | Step 1 asks `Legal business name` | Needed | Needed | Likely needed | Normal business data | Keep; helper should say exact Companies House registered name. | +| Trading / brand name | Step 1 asks `Trading name`; Step 2 asks sender display name | Needed for brand/sender | May help explain brand vs legal entity | Not primary | Normal business data | Keep; ensure it does not replace legal name. | +| Companies House number | Step 1 asks `Companies House number` | Useful/needed | Needed as registration number | Likely needed | Normal business data | Keep; consider wording `Companies House company number (CRN)`. | +| Company type | Step 1 asks `Registered company type`; sole traders excluded | Useful | Needed | May be needed | Normal business data | Align options to Twilio-compatible limited-company language where possible. | +| Business industry | Step 1 asks `Business industry` | Needed for sender/use case | Needed | Possibly useful | Normal business data | Align options to Twilio/Twilio-RCS categories where practical. | +| Website URL | Step 1 asks website; Step 3 asks customer-facing website | Needed | Needed and likely checked against business/brand | Likely needed | Normal business data | Strengthen review rule: live site should clearly match legal/trading brand and not be ambiguous. | +| Registered address | Step 1 asks Companies House registered office address | Useful | Business address needed | Emergency/number compliance may need address | Normal business data unless proof files are added | Keep; add note later if Twilio needs physical operating address separate from registered office. | +| Business regions of operation | Current Step 7 asks RCS destination countries, not company operating regions | Launch market info | Needed by Trust Hub as operations regions | Not the same as recipient countries | Normal business data | Add later or collect internally; do not confuse with RCS launch markets. | +| Primary contact | Step 1 asks name/email/phone | Operational | Operational | Operational | Personal contact data | Keep. | +| Authorised representative 1 | Step 1 asks name/email/job title; auto-syncs from primary contact | Needed for sign-off | Required primary rep per Isa/Twilio reply; phone and job position may also be needed | May be needed | Personal contact data | Expand later to first/last/email/phone/business title/job position if/when the Trust Hub lane is implemented. | +| Authorised representative 2 | Not currently captured | Usually not needed for RCS | Optional backup/future-proofing per Isa/Twilio reply | Unclear | Personal contact data | Do not force in first customer-facing launch form unless Bugs approves; collect manually or add optional later. | +| Passport / driving licence / proof of address | Not captured | Not needed for RCS form | Exception-only if Twilio cannot digitally verify rep/business association | Possibly separate KYC evidence | High sensitivity | Must not use static app/Google Sheet; use Twilio-managed compliance step or secure/manual route only. | +| Sender display name | Step 2 asks it | Needed | May relate to brand context | Not primary | Normal business data | Keep. | +| Logo, banner, brand colour | Step 2 asks uploads/colour | Needed | Not primary | Not primary | Brand assets | Keep in RCS form. | +| Public contact and policy links | Step 3 asks email, phone, website, privacy, terms | Needed | Website may overlap | May support compliance | Normal business data | Keep; review for brand ownership and live links. | +| Sender description and use case | Steps 4/5 ask purpose, description, examples | Needed and high review risk | Not primary | Not primary | Normal business data | Keep; RightOnQ should polish before submission. | +| Consent/opt-in/opt-out | Step 6 asks consent route, opt-in, opt-out | Needed and high review risk | Not primary | Not primary | Normal business data | Keep; RightOnQ should polish before submission. | +| RCS destination countries | Step 7 asks launch countries | Needed for RCS/cost planning | Different from Trust Hub operations regions | May influence number strategy | Normal business data | Keep; do not reuse as Trust Hub operations regions without review. | + +Immediate audit from this map: + +- The existing form is still a good RCS Part A base. +- Trust Hub adds a compliance layer, not a reason to throw the app away. +- The likely UI changes later are limited and focused: + - sharpen legal name/CRN/company type/industry wording; + - possibly add or internally collect business regions of operation; + - possibly expand primary authorised representative fields; + - optionally add or manually collect representative 2 later; + - keep ID/passport evidence out of the static app. + +## Field Change Shortlist - Draft 1 + +This shortlist translates the field authority map into practical build decisions. It should be reviewed with Bugs before editing the customer-facing form. + +### Safe To Change Now + +These changes are low-risk because they improve clarity for both RCS and KYC without adding sensitive data or changing the application structure. + +1. Rename/help-text `Companies House number` to make clear this is the Companies House company number / CRN. +2. Strengthen website helper text so the client understands the site must clearly match the legal/trading brand. +3. Tighten `Registered company type` options to reflect the UK limited-company audience and remove any option that makes RightOnQ look open to unsuitable entities. +4. Add internal review wording that public email/domain should preferably belong to the business, not free webmail. +5. Add a short note near Step 1 or completion that RightOnQ may need further KYC evidence before SMS fallback/UK numbers can go live, without asking for that evidence in this form. + +### Resolved By Isa Bell / Twilio Reply + +These points now have a clearer working answer. + +1. Customer-facing form should not require two authorised representatives for v1; use one required primary rep and optional second later/manual. +2. Passport/government ID should be exception-only, not mandatory upfront. +3. Secondary Compliance Profile and UK long-code RC Bundle should be treated as separate operational submissions/status lanes. +4. UK long-code numbers controlled by RightOnQ should still be assigned to the client/end-business compliance bundle/profile. + +### Still Needs Later Design + +1. Whether representative 1 should be split into first name / last name / phone / business title / job position now, or kept simpler for v1. +2. Whether Twilio needs physical operating address separate from Companies House registered office address. +3. Whether Trust Hub `business_regions_of_operation` should be asked on the client form, collected internally, or inferred/reviewed by RightOnQ. +4. Whether Twilio Compliance Embeddable becomes the preferred exception path for any ID/document collection. + +### Manual / Secure Only + +These must not be added to the current static app or Google Sheet submission path. + +1. Passport upload. +2. Driving licence upload. +3. Representative proof-of-address upload. +4. Date of birth, unless Twilio explicitly requires it and Bugs approves a secure collection/storage design. +5. Any ID document link that could be opened by anyone with a sheet/file URL. + +### Do Not Change Yet + +These areas are already doing useful RCS work and should stay stable while the KYC answer is pending. + +1. Step 2 brand profile assets and image validation. +2. Step 4/5 sender description, use case, and example message drafting. +3. Step 6 opt-in/opt-out wording. +4. Step 7 RCS launch markets. This is not the same thing as Trust Hub business regions of operation. +5. Part B name/logo and video approval storage. + +### Likely Next Form Edit Pass + +If Bugs approves a small no-regrets edit pass before Isa replies, the safest scope is: + +1. CRN wording. +2. Website/domain matching wording. +3. Company type option cleanup. +4. KYC evidence notice with no upload field. +5. Internal docs/schema labels only, not new sensitive fields. + +Status: approved by Bugs and implemented by RCS-Twilio-4 on Thursday 14 May 2026. + +Implemented in `rcs-registration/index.html`: + +- Step 1 now includes a calm UK KYC note explaining that RightOnQ may need extra business/identity evidence before UK SMS fallback numbers can go live, but no passport, driving licence, or proof-of-address documents should be uploaded in this form. +- Box 3 was renamed from `Companies House number` to `Companies House company number (CRN)`. +- Box 3 helper now says only UK Companies House registered businesses are accepted. +- Box 4 helper now says sole traders and unregistered businesses are not accepted. +- Box 4 options were tightened to Companies House limited-company style options: + - `Private limited company (Ltd)`; + - `Public limited company (PLC)`; + - `Limited liability partnership (LLP)`; + - `Community interest company (CIC)`; + - `Company limited by guarantee`. +- Business website and customer-facing website helpers now say the live site should clearly match/belong to the legal or trading brand. +- Authorised representative email and customer-facing email helpers now steer away from personal/free webmail where the business has its own domain. + +Still not changed: + +- no representative 2 fields; +- no date of birth field; +- no passport/driving-licence/proof-of-address upload; +- no Trust Hub operations-region field; +- no Apps Script schema change for KYC-only data. + +## Customer-Facing Journey + +Target smooth journey: + +1. Client expresses interest and is qualified by RightOnQ/outreach. +2. Client opens a RightOnQ onboarding page or receives a guided link. +3. Client sees the package and commercial terms: + - service level; + - monthly base fee; + - PAYG usage; + - starting usage credit/deposit; + - auto top-up / pause rules. +4. Client accepts service/payment terms. +5. Client pays via Revolut, likely first month plus starting usage credit. +6. Client receives a private onboarding/application link. +7. Client completes the intake once, with fields accurate enough for both RCS sender registration and Twilio Trust Hub/KYC. +8. RightOnQ checks the intake. +9. RightOnQ starts or prepares the Twilio Trust Hub Secondary Compliance Profile track where required. +10. Client sees Part B storyboard/status. +11. RightOnQ sends RBM Tester invitation and branded phone preview. +12. B2 unlocks for name/logo approval. +13. Client approves name/logo or sends issue feedback. +14. RightOnQ fixes issues or proceeds. +15. RightOnQ prepares review video. +16. B3 unlocks for video review. +17. Client approves video or requests changes. +18. RightOnQ submits registration. +19. B4 shows submitted/tracking state. +20. Client is notified of provider/carrier outcome. +21. Once Trust Hub/KYC, RCS approval, and commercial controls are ready, usage is monitored and charged/top-up controlled. + +## RightOnQ Internal Journey + +Target internal flow: + +1. Lead qualified. +2. Commercial offer agreed. +3. Revolut checkout/order/payment setup created. +4. Payment received and/or payment method saved. +5. Subscription/base monthly entitlement active or recorded. +6. Minimum usage credit/deposit received. +7. Application record created with stable `application_id`. +8. Private application link issued. +9. Part A submitted. +10. Part A reviewed by RightOnQ. +11. Registration details corrected/normalised if needed. +12. Trust Hub/KYC readiness checked; Secondary Compliance Profile created/prepared if required. +13. Twilio runtime subaccount created/prepared. +14. Phone preview/test invitation sent. +15. Application status updated to unlock B2. +16. Name/logo approval received or issue raised. +17. If issue raised, stop video work until resolved. +18. Review video prepared. +19. Application status updated to unlock B3. +20. Video approval received or changes requested. +21. If approved, registration pack submitted. +22. Provider/carrier status tracked. +23. Trust Hub/KYC, RCS approval, billing, and live-service gates maintained. +24. Twilio usage monitored. +25. Revolut top-ups/payments reconciled. +26. Service paused if billing risk rules trigger. + +## Outreach To Onboarding Handoff Contract + +This is the formal plug between the outreach/CRM office and the RCS onboarding +product flow. + +The outreach team must not hand a prospect to onboarding through an informal +chat note alone. The handoff needs a structured state that agents and later +automation can reliably detect. + +### Formal Trigger + +The CRM deal/status/tag trigger is: + +`READY_FOR_ONBOARDING` + +Meaning: + +- the lead has been qualified by RightOnQ/outreach; +- the prospect has shown enough interest or agreement to start the onboarding + process; +- RightOnQ is ready to create an onboarding application record; +- the next owner is the onboarding/product flow, not further cold outreach. + +Important distinction: + +- `READY_FOR_ONBOARDING` means the lead is ready to enter the controlled + onboarding path. +- It does not by itself mean commercial acceptance, billing setup, or provider + registration approval is complete. +- Those remain separate onboarding statuses and must be confirmed before live + Twilio-backed service or chargeable usage begins. + +### Source Of Truth Split + +Before customer acceptance: + +- OpenClaw CRM is the source of truth. +- It owns company, contact, outreach history, campaign context, notes, tasks, + and deal state. + +After an onboarding application exists: + +- the RCS onboarding sheet/app is the source of truth for registration, + payment, provider, Twilio, approval, and live-service status. +- CRM keeps summary status, links, notes, and owner prompts. +- CRM must not become a messy duplicate of the full Part A/Part B application. + +### Minimum Handoff Flow + +1. Outreach qualifies the lead in OpenClaw CRM. +2. Roy/Kate/Scott marks the CRM deal/status/tag as `READY_FOR_ONBOARDING`. +3. Onboarding creates a stable `application_id`. +4. Onboarding writes the `application_id` back to the CRM record/deal. +5. From that point, onboarding owns registration/payment/provider truth. +6. CRM receives summary updates and next-owner prompts only. + +### Minimum Handoff Fields + +The handoff should include: + +- `crm_company_id` +- `crm_deal_id` +- `company_name` +- `primary_contact_name` +- `primary_contact_email` +- `campaign_code` +- `message_code` +- `qualified_use_case` +- `package_interest` +- `handoff_date` +- `handoff_notes` or `sales_context` +- `application_id` once created +- `onboarding_status` once created +- `next_owner` + +### Handoff Notes / Sales Context + +`handoff_notes` or `sales_context` should explain why the prospect is moving +into onboarding. It should help the onboarding team understand: + +- what the prospect appeared to care about; +- which problem or use case resonated; +- what RightOnQ has already said or promised; +- what tone to use next; +- any risks, caveats, or unresolved questions. + +This field is deliberately human. It prevents the onboarding team from treating +every new application as if it arrived cold. + +### Implementation Rule + +For the pilot, this can be manual, but it must still be structured: + +- CRM must show `READY_FOR_ONBOARDING` before onboarding starts. +- The onboarding application must store the CRM IDs or a reliable CRM reference. +- The CRM record must receive the `application_id` after creation. +- The status bridge should be easy for agents to read before it is automated. + +## Status Model + +Initial statuses to consider: + +- `lead_qualified` +- `commercial_offer_sent` +- `commercial_accepted` +- `billing_setup_started` +- `billing_active` +- `usage_credit_paid` +- `application_created` +- `part_a_link_sent` +- `part_a_started` +- `part_a_submitted` +- `part_a_internal_review` +- `part_a_changes_needed` +- `part_a_accepted` +- `phone_preview_sent` +- `name_logo_approved` +- `name_logo_changes_requested` +- `video_preparing` +- `video_ready_for_review` +- `video_approved` +- `video_changes_requested` +- `registration_submitted` +- `provider_review` +- `provider_changes_requested` +- `approved` +- `rejected` +- `live` +- `paused_billing` +- `paused_operational` + +Keep the first implementation smaller if needed, but do not lose these concepts. + +## Source Of Truth Direction + +For the pilot, a structured Google Sheet is acceptable if the schema is disciplined. + +Likely tabs: + +- `Applications` +- `Billing` +- `Part A` +- `Part B approvals` +- `Internal reviews` +- `Trust Hub KYC` +- `Twilio setup` +- `Communications` +- `Status log` + +Potential later move: + +- Keep the sheet as an operator-friendly dashboard. +- Move canonical storage into a database when the workflow needs stronger locking, tokens, admin UI, or real-time status control. + +## Source Of Truth Schema - Draft 1 + +This is the first proposed Google Sheet schema for the pilot. It is intentionally operator-friendly and status-led. + +Principles: + +- Every client/application has one stable `application_id`. +- Customer-facing submissions are preserved. +- RightOnQ internal decisions/statuses are tracked separately from raw customer answers. +- Payment state and registration state are related but not the same thing. +- Client communications are logged so the client does not disappear into a black hole. +- For v1, manual RightOnQ updates are acceptable if they are explicit and timestamped. + +### Tab: Applications + +Purpose: one row per client application. This is the control row. + +Primary writer: + +- system on application creation; +- RightOnQ manually for statuses and internal notes. + +Suggested columns: + +- `application_id` +- `client_id` +- `crm_company_id` +- `crm_deal_id` +- `crm_source_record_url` +- `private_application_token` +- `client_name` +- `legal_business_name` +- `trading_name` +- `primary_contact_name` +- `primary_contact_email` +- `primary_contact_phone` +- `campaign_code` +- `message_code` +- `qualified_use_case` +- `package_interest` +- `handoff_date` +- `sales_context` +- `package_name` +- `registration_status` +- `billing_status` +- `part_a_status` +- `part_b_status` +- `twilio_status` +- `trust_hub_status` +- `provider_status` +- `internal_owner` +- `created_at` +- `updated_at` +- `last_client_action_at` +- `last_internal_action_at` +- `next_action_owner` +- `next_action_note` +- `internal_notes` + +Initial statuses: + +- `commercial_accepted` +- `billing_active` +- `application_created` +- `trust_hub_not_started` +- `trust_hub_draft` +- `trust_hub_pending_review` +- `trust_hub_approved` +- `trust_hub_rejected` +- `part_a_submitted` +- `part_a_internal_review` +- `part_a_accepted` +- `phone_preview_sent` +- `name_logo_approved` +- `video_ready_for_review` +- `video_approved` +- `registration_submitted` +- `provider_review` +- `approved` +- `live` +- `paused_billing` + +### Tab: Billing + +Purpose: commercial/payment state and usage-credit control. + +Primary writer: + +- Revolut webhook/API sync where possible; +- RightOnQ manually for pilot reconciliation; +- future automation for top-up and pause rules. + +Suggested columns: + +- `application_id` +- `client_id` +- `package_name` +- `monthly_base_fee_gbp` +- `starting_usage_credit_gbp` +- `initial_payment_due_gbp` +- `initial_payment_status` +- `initial_payment_revolut_order_id` +- `revolut_customer_id` +- `revolut_payment_method_id` +- `revolut_subscription_id` +- `subscription_status` +- `usage_credit_balance_gbp` +- `top_up_threshold_gbp` +- `top_up_amount_gbp` +- `auto_top_up_status` +- `last_top_up_attempt_at` +- `last_top_up_status` +- `last_payment_status` +- `billing_pause_flag` +- `billing_pause_reason` +- `billing_notes` +- `updated_at` + +Starting assumptions to test: + +- `package_name`: `Local Time Only` +- `monthly_base_fee_gbp`: `25` +- `starting_usage_credit_gbp`: `50` +- `initial_payment_due_gbp`: `75` +- `top_up_threshold_gbp`: to be agreed +- `top_up_amount_gbp`: to be agreed + +### Tab: Part A + +Purpose: the submitted registration data from the customer-facing Part A form. + +Primary writer: + +- current `rcs-registration/index.html` submission via Apps Script; +- later, updates should include `application_id`. + +Suggested column groups: + +- record metadata: + - `application_id` + - `submission_id` + - `submitted_at` + - `source_version` + - `client_ip_or_user_agent_hash` if ever needed and privacy-approved +- business details: + - `legal_business_name` + - `trading_name` + - `companies_house_number` + - `company_type` + - `registration_country` + - `registered_address_line_1` + - `registered_address_line_2` + - `registered_city` + - `registered_county` + - `registered_postcode` + - `business_website` + - `business_industry` +- contacts: + - `primary_contact_name` + - `primary_contact_email` + - `primary_contact_phone` + - `authorised_rep_1_first_name` + - `authorised_rep_1_last_name` + - `authorised_rep_1_email` + - `authorised_rep_1_phone` + - `authorised_rep_1_business_title` + - `authorised_rep_1_job_position` + - `authorised_rep_2_first_name` + - `authorised_rep_2_last_name` + - `authorised_rep_2_email` + - `authorised_rep_2_phone` + - `authorised_rep_2_business_title` + - `authorised_rep_2_job_position` +- brand profile: + - `sender_display_name` + - `brand_colour` + - `logo_filename` + - `logo_validation_status` + - `banner_filename` + - `banner_validation_status` +- public profile/contact: + - `customer_email` + - `customer_phone` + - `customer_website` + - `privacy_policy_url` + - `terms_url` + - `rightonq_updates_email` +- message purpose: + - `primary_use_case` + - `sender_description` + - `monthly_volume` + - `message_trigger` + - `use_case_description` +- message examples: + - `example_message_1` + - `example_message_2` + - `help_sample_message` + - `stop_sample_message` +- consent/markets: + - `consent_routes` + - `consent_route_source` + - `opt_in_description` + - `opt_out_description` + - `reviewer_access` + - `launch_markets` + - `us_contact_count` + - `existing_us_messaging_activity` +- signoff: + - `accuracy_declaration` + - `agency_submission_declaration` + - `signatory_name` + - `signatory_title` + - `iphone_preview_number` + - `android_preview_number` + - `signoff_date` + +### Tab: Part B Approvals + +Purpose: customer approvals/issues after Part A. + +Primary writer: + +- future B2/B3 forms; +- RightOnQ manually for pilot if needed. + +Suggested columns: + +- `application_id` +- `part_b_event_id` +- `event_type` +- `event_status` +- `submitted_at` +- `submitted_by_name` +- `submitted_by_email` +- `phone_preview_sent_at` +- `tester_invitation_received` +- `branded_message_received` +- `name_logo_decision` +- `name_logo_issue_categories` +- `name_logo_issue_notes` +- `name_logo_approved_at` +- `video_url` +- `video_sent_at` +- `video_decision` +- `video_checklist_sender_name` +- `video_checklist_logo_banner` +- `video_checklist_message_examples` +- `video_checklist_permission_route` +- `video_checklist_opt_out_route` +- `video_change_notes` +- `video_approved_at` +- `rightonq_follow_up_required` + +Recommended `event_type` values: + +- `name_logo_approval` +- `name_logo_issue` +- `video_approval` +- `video_change_request` + +### Tab: Trust Hub KYC + +Purpose: Twilio Trust Hub Secondary Compliance Profile / client KYC tracking. + +This is separate from the Twilio runtime subaccount. The subaccount is for runtime resources and billing/usage separation. Trust Hub is the compliance/KYC record and should be tracked as its own lane. + +Primary writer: + +- RightOnQ manually for pilot; +- later automation using Twilio Trust Hub API. + +Suggested columns: + +- `application_id` +- `client_id` +- `primary_customer_profile_sid` +- `secondary_customer_profile_sid` +- `trust_hub_policy_sid` +- `trust_hub_profile_friendly_name` +- `trust_hub_status` +- `trust_hub_status_updated_at` +- `trust_hub_status_callback_configured` +- `trust_hub_rejection_reason` +- `trust_hub_error_code` +- `trust_hub_error_detail` +- `business_identity` +- `business_type` +- `business_industry` +- `business_registration_identifier` +- `business_registration_number` +- `business_regions_of_operation` +- `business_website_match_status` +- `address_sid` +- `address_validation_status` +- `supporting_document_sid` +- `business_info_end_user_sid` +- `authorised_rep_1_end_user_sid` +- `authorised_rep_2_end_user_sid` +- `authorised_rep_1_validation_status` +- `authorised_rep_2_validation_status` +- `authorised_rep_exception_code` +- `authorised_rep_exception_action` +- `primary_profile_assignment_status` +- `business_info_assignment_status` +- `rep_1_assignment_status` +- `rep_2_assignment_status` +- `address_assignment_status` +- `evaluation_status` +- `evaluation_last_run_at` +- `evaluation_error_summary` +- `channel_endpoint_assignment_status` +- `phone_number_sid` +- `kyc_internal_notes` +- `updated_at` + +Recommended `trust_hub_status` values: + +- `not_started` +- `draft` +- `evaluation_failed` +- `ready_to_submit` +- `pending_review` +- `in_review` +- `twilio_approved` +- `twilio_rejected` +- `not_required_rcs_only` + +Recommended exception codes to track: + +- `18019`: proof of identity required for authorised representative. +- `18020`: proof of authorised representative's association with business required. +- `18057`: authorised representative validation failed. + +Launch privacy rule: + +- Do not store representative date of birth, ID images, or proof-of-address files in the current static form / Google Sheet workflow unless Bugs explicitly approves a secure storage design. +- If Twilio requires sensitive representative evidence, prefer Twilio-managed compliance collection where available, or handle it as a secure manual follow-up/later backend-admin flow. + +### Tab: UK RC Bundles + +Purpose: UK long-code Regulatory Compliance Bundle tracking for SMS fallback numbers. + +This is separate from the Secondary Compliance Profile. The Secondary Compliance Profile models/verifies the end-client business; the UK RC Bundle is the number-compliance approval for UK local, national, mobile, or toll-free long-code usage. + +Primary writer: + +- RightOnQ manually for pilot; +- later automation using Twilio Regulatory Compliance APIs. + +Suggested columns: + +- `application_id` +- `client_id` +- `rc_bundle_sid` +- `rc_bundle_status` +- `rc_bundle_status_updated_at` +- `rc_bundle_rejection_reason` +- `rc_bundle_error_code` +- `rc_bundle_error_detail` +- `end_business_legal_name` +- `business_registration_number` +- `number_type` +- `phone_number_sid` +- `phone_number` +- `phone_number_assignment_status` +- `address_sid` +- `supporting_document_sid` +- `compliance_owner` +- `fallback_required` +- `internal_notes` +- `updated_at` + +Recommended `rc_bundle_status` values: + +- `not_started` +- `draft` +- `pending_review` +- `in_review` +- `twilio_approved` +- `twilio_rejected` +- `not_required_unless_uk_long_code` + +Launch note: + +- UK long-code fallback numbers must be assigned to the end-business bundle before use. +- This tab stores Twilio IDs, statuses, and rejection reasons; it must not store raw ID documents. + +### Tab: Internal Reviews + +Purpose: RightOnQ operator checklist for reviewing Part A before phone preview, Trust Hub/KYC work, or RCS submission moves forward. + +Primary writer: + +- system when Part A is received; +- RightOnQ manually for checklist status and notes during pilot. + +Suggested columns: + +- `created_at` +- `application_id` +- `review_status` +- `assigned_owner` +- `legal_company_check` +- `website_domain_check` +- `public_links_check` +- `message_purpose_examples_check` +- `consent_opt_out_check` +- `kyc_trust_hub_check` +- `sms_fallback_rc_bundle_check` +- `phone_preview_readiness` +- `next_action` +- `notes` +- `source_status` +- `updated_at` + +Initial checklist state: + +- `review_status`: `pending_review` +- `assigned_owner`: `RightOnQ` +- checklist items: `pending` +- `kyc_trust_hub_check`: `pending_trust_hub_review` + +Implementation note: + +- This checklist is internal only. +- It must not request or store passport, driving licence, proof-of-address files, or date of birth. +- Its job is to make the manual RightOnQ review visible and repeatable before the application moves forward. + +### Tab: Twilio Setup + +Purpose: internal runtime setup and provider/Twilio tracking. + +Primary writer: + +- RightOnQ manually for pilot; +- later automation can populate subaccount and usage fields. + +Suggested columns: + +- `application_id` +- `client_id` +- `twilio_subaccount_sid` +- `twilio_subaccount_friendly_name` +- `twilio_messaging_service_sid` +- `rbm_agent_id` +- `rbm_sender_name` +- `rbm_logo_url` +- `rbm_banner_url` +- `provider_submission_reference` +- `provider_submission_status` +- `provider_submitted_at` +- `provider_last_checked_at` +- `provider_notes` +- `phone_preview_status` +- `phone_preview_sent_at` +- `review_video_url` +- `review_video_status` +- `registration_pack_status` +- `go_live_status` +- `go_live_date` +- `manual_pause_flag` +- `manual_pause_reason` + +### Tab: Status Log + +Purpose: append-only audit trail. This should not be edited casually. + +Primary writer: + +- system for form/payment events; +- RightOnQ manually for important internal status changes. + +Suggested columns: + +- `event_id` +- `application_id` +- `event_at` +- `event_actor` +- `event_source` +- `previous_status` +- `new_status` +- `event_type` +- `event_summary` +- `event_payload_json` +- `internal_note` + +Useful `event_source` values: + +- `customer_form` +- `rightonq_manual` +- `revolut_webhook` +- `twilio_usage_sync` +- `provider_update` +- `system` + +### Tab: Communications + +Purpose: customer-facing email/message cadence and send log. + +Primary writer: + +- RightOnQ manually for pilot; +- later automation triggered by status changes. + +Why this matters: + +- Clients should receive clear safe-receipt and next-step messages. +- RightOnQ should know which emails were sent, when, and from which template. +- Later automation can be added without changing the core workflow. + +Suggested columns: + +- `communication_id` +- `application_id` +- `client_id` +- `recipient_name` +- `recipient_email` +- `communication_type` +- `trigger_status` +- `trigger_event_id` +- `subject` +- `template_version` +- `sent_at` +- `sent_by` +- `send_method` +- `delivery_status` +- `requires_reply` +- `reply_received_at` +- `next_follow_up_at` +- `notes` + +Recommended `communication_type` values: + +- `payment_received_onboarding_started` +- `application_link_sent` +- `part_a_received` +- `part_a_accepted_phone_preview_next` +- `phone_preview_sent` +- `name_logo_approved` +- `name_logo_issue_received` +- `video_ready` +- `video_approved` +- `registration_submitted` +- `provider_update` +- `action_needed` +- `approved_live` +- `billing_issue` +- `top_up_failed` +- `service_paused` + +Minimum v1 triggered/manual email cadence: + +1. Payment received / onboarding started: + - confirm RightOnQ RCS onboarding has started; + - explain the next step; + - provide or promise the private application link. +2. Application link sent: + - give the private Part A link; + - explain what the client needs to complete. +3. Part A received: + - safe receipt; + - RightOnQ will check and process the written details. +4. Part A accepted / phone preview next: + - written details are ready; + - RightOnQ will send the RBM Tester invitation and branded phone preview. +5. Phone preview sent: + - ask client to check phone; + - accept RBM Tester invitation; + - return to B2 to approve or raise an issue. +6. Name/logo approved: + - confirm approval; + - RightOnQ will prepare the review video. +7. Name/logo issue received: + - safe receipt of issue; + - RightOnQ will pause video preparation and review/fix. +8. Video ready: + - ask client to review and approve video in Part B. +9. Video approved: + - confirm approval; + - RightOnQ will submit the registration pack. +10. Registration submitted: + - confirm submission; + - explain typical provider/carrier review timing. +11. Provider update / action needed: + - use only when there is an update, question, rejection, or required client action. +12. Approved/live: + - confirm approval/live status; + - explain what happens with ongoing RightOnQ service, billing, and usage monitoring. +13. Billing issue / top-up failed / service paused: + - clear but calm notice that payment/top-up needs attention before sending can continue. + +### First Schema Decision Needed + +Before implementation, decide whether Part A should: + +1. keep appending full submissions to `Part A` and update the single control row in `Applications`; or +2. update one row only per application. + +Recommendation for v1: + +- Keep `Part A` append-only for audit/recovery. +- Keep `Applications` as the current control row. +- Use `Status Log` for every important state transition. + +## Core Identifiers + +Minimum identifiers to track: + +- `application_id` +- `client_id` +- `client_name` +- `created_at` +- `updated_at` +- `registration_status` +- `billing_status` +- `revolut_customer_id` +- `revolut_order_id` +- `revolut_payment_method_id` +- `revolut_subscription_id` +- `usage_credit_balance` +- `last_payment_status` +- `twilio_subaccount_sid` +- `twilio_messaging_service_sid` +- `provider_submission_reference` +- `provider_status` +- `private_application_token` +- `internal_owner` +- `last_communication_at` +- `next_follow_up_at` + +## Existing RCS Form Fit + +Current app: + +- `/Users/macpro/rightonq-code.github.io/rcs-registration/index.html` + +Current shape: + +- Part A is the customer data capture application. +- Part B is currently a static visible preview/workflow: + - B1 storyboard; + - B2 approve name and logo; + - B3 review and approve video; + - B4 registration submitted. + +Known gap: + +- Part B is not yet status-controlled or wired to persistent B2/B3 submissions. + +## Build Slices + +### Slice 1 - Main Build Plan + +Create and maintain this file. + +Status: started by RCS-Twilio-4 on Thursday 14 May 2026. + +### Slice 2 - Source Of Truth Schema + +Define the exact Google Sheet tabs and headers. + +Status: draft 1 added by RCS-Twilio-4 on Thursday 14 May 2026. + +Output: + +- agreed sheet schema; +- field names; +- which fields are customer-facing vs internal; +- which fields are generated by system; +- which fields are manually updated by RightOnQ. + +### Slice 3 - Application ID And Status In Part A + +Add stable application identity to the current Part A flow. + +Status: v1 implemented and tested by RCS-Twilio-4 on Thursday 14 May 2026. + +Temporary v1 decision: + +- Generate `application_id` inside the browser form for now. +- Persist it in autosave/progress/download/submission payloads. +- Use it to connect Part A submissions to the future `Applications` control row. +- Before launch, move `application_id` generation to a RightOnQ-created private link or server-side application record. + +Implementation note: + +- Apps Script must store `application_id`, `registration_status`, and `part_a_status`. +- The live Google Sheet headers must be updated before deploying/using the changed Apps Script, because these fields are inserted near the start of the append row. +- Header row update completed by RCS-Twilio-4 on Thursday 14 May 2026 for `Part A submissions!A1:AI1`. +- Apps Script project was pushed and existing live deployment `AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6` was redeployed in place to version `4`. +- Test POSTs wrote rows with `Application ID`, `Registration status`, and `Part A status` correctly populated. Two obvious test rows exist in the live sheet using `ROQ-RCS-TEST-SLICE3-20260514`. + +Reason: + +- Browser-generated ID is enough to start wiring the workflow spine. +- Private application links are still a later slice. +- This must not be treated as final launch architecture. + +Output: + +- `application_id`; +- `private_application_token` or equivalent; +- initial `registration_status`; +- Part A submission updates one application record or appends an event tied to application ID. + +### Slice 4 - Internal Status Control + +Give RightOnQ a manual way to update status for pilot use. + +Status: first thin version implemented and tested by RCS-Twilio-4 on Thursday 14 May 2026. + +Possible v1: + +- Google Sheet status columns manually edited. This is the current pilot direction. +- App reads status by token/application ID. First version now reads by `applicationId`. +- RightOnQ manually sends the next link/state while private-link generation is still pending. + +Implemented in first thin version: + +- Apps Script `GET ?applicationId=...` returns the latest matching row's: + - `registrationStatus`; + - `partAStatus`; + - `reviewStatus`; + - `partBVideoStatus`; + - `notes`; + - `lastUpdated`. +- Existing live Apps Script deployment `AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6` redeployed in place to version `5`. +- Static app accepts `?applicationId=...` or `?application_id=...`, stores it locally, and refreshes status from the live Apps Script endpoint. +- Part B rail displays the current status and marks B2/B3/B4 as waiting or available. +- B2/B3/B4 remain visible for planning, but the copy clearly says when each stage becomes live. + +Test evidence: + +- Live status lookup for `ROQ-RCS-TEST-SLICE3-20260514` returned `registrationStatus: part_a_submitted`. +- Local browser preview at `http://localhost:8902/rcs-registration/index.html?applicationId=ROQ-RCS-TEST-SLICE3-20260514` showed `Part A received` and kept B2 as `Waiting for test message`. + +Important launch caveat: + +- This is still not the final private-link architecture. +- Before launch, RightOnQ should create the application row/link before the client starts Part A, then send a private link containing a non-guessable token or equivalent. + +### Slice 4A - Applications Control Row + +Give each application a one-row internal control record, separate from the append-only Part A submission log. + +Status: first thin version implemented and tested by RCS-Twilio-4 on Thursday 14 May 2026. + +Implemented: + +- Apps Script now creates the `Applications` tab if needed. +- Apps Script writes one row per `Application ID`. +- Part A submissions still append to `Part A submissions` for audit/recovery. +- The `Applications` row stores CRM handoff fields when supplied: + - `CRM company ID`; + - `CRM deal ID`; + - `CRM source record URL`; + - `Campaign code`; + - `Message code`; + - `Qualified use case`; + - `Package interest`; + - `Handoff date`; + - `Sales context`. +- Status lookup now checks `Applications` first and falls back to `Part A submissions`. +- Existing live Apps Script deployment `AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6` redeployed in place to version `6`. + +Test evidence: + +- Test submission `ROQ-RCS-TEST-SLICE5-20260514` wrote to both `Part A submissions` and `Applications`. +- `Applications` row included test CRM fields, package interest, handoff date, and sales context. +- Live status lookup for `ROQ-RCS-TEST-SLICE5-20260514` returned status data from the `Applications` shape, including billing/Part B/Twilio/provider status fields. + +Important launch caveat: + +- This still uses the browser/generated Application ID when the client starts from the public static page. +- The next launch-safe move is to create the `Applications` row first, generate a private token/link, then send that private link to the client. + +### Slice 4B - Private Application Token Path + +Prepare the launch-safe route where RightOnQ creates the application before the client starts Part A. + +Status: guarded version implemented and proof-tested by RCS-Twilio-4 on Thursday 14 May 2026. + +Implemented: + +- Static app accepts private link parameters: + - `applicationId` or `application_id`; + - `applicationToken`, `privateApplicationToken`, `private_application_token`, or `token`. +- Static app stores the token locally for status checks and submission, but does not include it in the downloaded client copy. +- Status lookup can read by Application ID and token. +- If a token is supplied and does not match the `Applications` row, status lookup returns `found: false`. +- Part A submission into a token-protected `Applications` row now requires the matching private token. +- Apps Script has a guarded internal `action: createApplicationDraft` path. +- That action requires the script property `ONBOARDING_CREATE_PIN`, generates a private token, creates/updates the `Applications` row, and returns a private application link. +- Token-protected application status now requires the matching token. Application ID alone returns `found: false` for token-protected rows. +- Existing live Apps Script deployment `AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6` was redeployed in place to version `11` after proof cleanup. + +Test evidence: + +- Normal status lookup for `ROQ-RCS-TEST-SLICE5-20260514` still returned the expected application status. +- Status lookup for that Application ID with a wrong token returned `found: false`. +- Attempted `createApplicationDraft` without a configured PIN did not create a row in `Applications`. +- Temporary proof route created `ROQ-RCS-TEST-PIN-20260514173653`, returned a private link shape, submitted Part A against the same token-protected application, and confirmed: + - draft status moved from `application_created`; + - Part A status became `part_a_submitted`; + - wrong token returned `found: false`. +- Temporary proof route/helper was removed before final deployment. +- Final live checks confirmed: + - token-protected app without a token returns `found: false`; + - token-protected app with a wrong token returns `found: false`; + - older non-token test app still returns status by Application ID. + +Important launch caveat: + +- The proof used a temporary PIN and removed/restored the script property afterwards. +- `ONBOARDING_CREATE_PIN` is not stored in the repo and must be configured in Apps Script properties before ongoing internal draft creation can be used. +- A proper operator/admin interface is still future work. This is the guarded plumbing layer only. + +### Slice 5 - Part B Unlocks + +Make Part B stages reflect real application status. + +Output: + +- B1 visible after Part A; +- B2 unlocked after `phone_preview_sent`; +- B3 unlocked after `video_ready_for_review`; +- B4 visible after `registration_submitted`. + +### Slice 6 - B2/B3 Submission Storage + +Persist client approval/issue responses. + +Status: B2 name/logo storage and B3 video approval/change storage implemented and proof-tested by RCS-Twilio-4 on Thursday 14 May 2026. + +Checkpoint: + +- Remote branch already includes the B2 checkpoint commits: + - `062cee9 Wire B2 name logo approval storage`; + - `0b04957 Update RCS handover after B2 storage`. +- Local B3 implementation checkpoint exists: + - `9dd3206 Wire B3 video approval storage`. +- This final build-plan/handover update sits on top of the B3 implementation checkpoint. +- Bugs approved pushing the B3 checkpoint and this build-plan/handover update. + +Output: + +- B2 name/logo approval record - done via `Part B approvals`; +- B2 issue record with categories/notes - done via `Part B approvals`; +- B2 status updates based on response - done via `Applications`; +- B3 video approval record - done via `Part B video approvals`; +- B3 change request record - done via `Part B video approvals`; +- B3 status updates based on response - done via `Applications`. + +Implemented: + +- Static app B2 `Approve name and logo` now posts `action = submitNameLogoApproval`. +- Payload includes Application ID, private application token when present, tester invite answer, name/logo decision, issue categories, notes, and submitted timestamp. +- Apps Script appends each response to a new `Part B approvals` event-log tab. +- Apps Script updates the matching `Applications` row: + - approval sets `registrationStatus` and `partBStatus` to `name_logo_approved`; + - not-arrived/help/issue/note sets both to `name_logo_changes_requested`; + - `Next action owner` becomes `RightOnQ`. +- Existing live Apps Script deployment `AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6` was redeployed in place to version `12`. +- Static app B3 `Review and approve video` now posts `action = submitVideoApproval`. +- Payload includes Application ID, private application token when present, approval checklist, change decision, change notes, and submitted timestamp. +- Apps Script appends each B3 response to a new `Part B video approvals` event-log tab. +- Apps Script updates the matching `Applications` row: + - approval sets `registrationStatus` and `partBStatus` to `video_approved`; + - change request sets both to `video_changes_requested`; + - `Next action owner` becomes `RightOnQ`. +- Existing live Apps Script deployment was redeployed in place to version `13`. + +Test evidence: + +- Live test POST against `ROQ-RCS-TEST-SLICE5-20260514` returned `ok: true` and `name_logo_approved`. +- Live Sheet now has the `Part B approvals` tab with labelled B2 test approval rows. +- `Applications` row for `ROQ-RCS-TEST-SLICE5-20260514` now shows `Registration status = name_logo_approved`, `Part B status = name_logo_approved`, `Next action owner = RightOnQ`, and `Next action note = Prepare the RCS application review video.` +- Browser check on `http://localhost:8902/rcs-registration/index.html?applicationId=ROQ-RCS-TEST-SLICE5-20260514` showed B2 opening correctly, approval choices enabling the `Send approval to RightOnQ` button, status reading `Name and logo approved`, and no console errors. +- Live B3 test POST against `ROQ-RCS-TEST-SLICE5-20260514` returned `ok: true` and `video_approved`. +- Live Sheet now has the `Part B video approvals` tab with a labelled B3 test approval row. +- `Applications` row for `ROQ-RCS-TEST-SLICE5-20260514` now shows `Registration status = video_approved`, `Part B status = video_approved`, `Next action owner = RightOnQ`, and `Next action note = Submit the RCS registration pack.` +- Browser check on the same URL showed B3 opening correctly, the five approval checklist items enabling `Send approval to RightOnQ`, status reading `Video approved`, and no console errors. + +Important caveat: + +- Three duplicate labelled test rows exist in `Part B approvals` because Apps Script's redirect behaviour wrote during the first curl attempts. Leave them as proof rows unless Bugs approves cleanup. + +Next: + +- Push the local B3 commits to `origin/rcs-registration-part-a-b-20260507`. +- Next build slice should move to Slice 6A communications cadence or the manual internal status update/operator view. + +### Slice 6A - Internal Status Operator Path + +Give RightOnQ a guarded backend route for moving applications through the manual gates without editing the `Applications` row directly. + +Status: guarded backend route implemented and deployed by RCS-Twilio-4 on Thursday 14 May 2026. Real operational use still needs `ONBOARDING_OPERATOR_PIN` configured or a wrapper built. + +Implemented: + +- Apps Script now supports `action = updateApplicationStatus`. +- The action requires script property `ONBOARDING_OPERATOR_PIN`. +- It can update selected `Applications` control-row fields: + - `Registration status`; + - `Billing status`; + - `Part A status`; + - `Part B status`; + - `Twilio status`; + - `Provider status`; + - `Internal owner`; + - `Next action owner`; + - `Next action note`; + - `Internal notes`. +- It writes `Updated at` and `Last internal action at`. +- Successful updates append an audit row to `Status events`. +- Audit JSON now redacts private application tokens, application tokens, create PINs, and operator PINs before storage. +- The browser status label list now recognises the full current backend registration status order, including internal review/change/provider/live/paused statuses. +- Existing live Apps Script deployment was redeployed in place to version `14`. + +Test evidence: + +- Apps Script syntax passed via `new Function(...)`. +- Inline `index.html` script syntax passed via extracted script parse. +- `git diff --check` passed for scoped files. +- Live unauthorised `updateApplicationStatus` attempt against `ROQ-RCS-TEST-SLICE5-20260514` returned `ok: false` with `ONBOARDING_OPERATOR_PIN is not configured`. +- The same test application remained at `Registration status = video_approved` and `Part B status = video_approved`, proving the guard did not mutate the control row without the operator PIN. + +Important caveat: + +- This is not yet an operator UI. +- Positive live status-change proof is still pending until Bugs chooses how to configure `ONBOARDING_OPERATOR_PIN` or asks for a small internal wrapper. +- Recommended next activation step: configure the real operator PIN in Apps Script properties, or build an internal wrapper so agents do not handle the PIN manually. + +### Slice 6B - Communications Cadence + +Define and implement customer communication templates and triggers. + +Status: first manual-send queue implemented and proof-tested by RCS-Twilio-4 on Thursday 14 May 2026. + +Output: + +- first email templates - partially done; +- trigger statuses - partially done; +- `Communications` tab write path - done; +- manual-send fallback for v1 - done; +- later automation plan. + +Implemented: + +- Apps Script now has a `Communications` manual-send queue tab. +- Future Part A submissions queue `part_a_received`. +- Future B2 name/logo responses queue: + - `name_logo_approved_received`; + - `name_logo_feedback_received`. +- Future B3 video responses queue: + - `video_approved_received`; + - `video_changes_received`. +- Future guarded internal status updates can queue: + - `part_a_accepted`; + - `phone_preview_sent`; + - `video_ready_for_review`; + - `registration_submitted`. +- Templates are stored as draft body text in the Sheet and marked `queued_manual_send`. +- No customer email is sent automatically yet. +- Existing live Apps Script deployment was redeployed in place to version `15`. + +Test evidence: + +- Apps Script syntax passed via `new Function(...)`. +- `git diff --check` passed for scoped files. +- Live labelled Part A test submission `ROQ-RCS-TEST-COMMS-202605141832` returned `ok: true`. +- Live `Applications` tab now contains the labelled communications test row. +- Live `Communications` tab contains a queued `part_a_received` draft addressed to `test-comms@example.com`. + +Important caveat: + +- The live Part A proof also ran the existing Adam notification path. +- This is a queue, not an auto-send system. +- Next step should be either template wording review/polish or an internal send/review workflow, not immediate automatic customer email sending. + +### Slice 6C - Trust Hub / KYC Field Authority Planning + +Status: planning update added by RCS-Twilio-4 on Thursday 14 May 2026 after live Twilio Console/API discovery from Bugs and the assisting agent. + +Purpose: + +- avoid building the RCS intake as if RCS sender registration is the only approval track; +- map RightOnQ intake fields to the stricter of RCS sender registration and Twilio Trust Hub/KYC requirements; +- keep Trust Hub compliance profile work separate from Twilio subaccount/runtime setup. + +Current field-authority decisions: + +- Legal business name should be exact Companies House / registered name. +- Registration number should be captured as the Companies House CRN for UK Ltd clients. +- Business type, industry, regions of operation, website, registered address, and the primary authorised representative should be shaped to satisfy Trust Hub first, then reused for RCS where possible. +- Build for one required primary authorised representative and optional/manual second representative, following Isa Bell's Twilio reply on Thursday 14 May 2026. +- Do not collect date of birth in the launch intake unless Twilio's live flow explicitly requires it. + +Implementation stance: + +- First live version should stay manual: RightOnQ reviews intake, then enters/creates the Secondary Compliance Profile in Twilio Console or a guarded internal workflow. +- API automation should come after the manual process is proven and after the required fields are verified from Twilio's live policy/evaluation resources. +- Do not submit fake/test profiles to Twilio review. Keep test profiles clearly labelled draft-only. + +### Slice 6D - Internal Review Checklist + +Status: first thin implementation added by RCS-Twilio-4 on Thursday 14 May 2026. + +Purpose: + +- give RightOnQ an operator checklist when Part A lands; +- keep the manual review visible before phone preview, Trust Hub/KYC, or RCS submission work moves forward; +- avoid building a full admin UI before the workflow has settled. + +Implemented: + +- Apps Script now defines an `Internal reviews` tab. +- Future Part A submissions append one checklist row to `Internal reviews`. +- The checklist includes: + - legal/company check; + - website/domain check; + - public links check; + - message purpose/examples check; + - consent/opt-out check; + - KYC/Trust Hub check; + - SMS fallback/RC bundle check; + - phone preview readiness; + - next action; + - notes. +- `Applications` now has a `Trust Hub status` control field. +- Guarded internal status updates can now update `trustHubStatus`. +- Existing live Apps Script deployment was redeployed in place to version `16`. + +Test evidence: + +- Apps Script syntax passed via `new Function(...)`. +- `git diff --check` passed for scoped files. +- Live labelled Part A test submission `ROQ-RCS-TEST-REVIEW-202605142008` returned `ok: true`. +- Live `Internal reviews` tab contains a pending checklist row for `ROQ-RCS-TEST-REVIEW-202605142008`. +- Live `Applications` tab contains `Trust Hub status = not_started` for `ROQ-RCS-TEST-REVIEW-202605142008`. +- Live status lookup for `ROQ-RCS-TEST-REVIEW-202605142008` returns `trustHubStatus: not_started`. + +Important caveat: + +- This is not a full operator dashboard. +- It is a sheet-backed internal checklist and status spine only. +- It does not request, upload, store, or link sensitive ID evidence. +- Two earlier labelled curl attempts displayed a Google Drive error page because the redirect was followed incorrectly, but they still reached the Apps Script backend and wrote test rows `ROQ-RCS-TEST-REVIEW-202605142006` and `ROQ-RCS-TEST-REVIEW-202605142007`. Leave them as obvious proof rows unless Bugs asks for cleanup. + +### Slice 6E - Guarded Internal Review Update Action + +Status: implemented and deployed by RCS-Twilio-4 on Thursday 14 May 2026. + +Purpose: + +- make the `Internal reviews` checklist actionable without manually editing every cell; +- keep the same operator-PIN guard used by internal status changes; +- allow RightOnQ to mark Part A accepted from the review workflow when the checklist is ready. + +Implemented behaviour: + +- Apps Script supports `action = updateInternalReview`. +- The action requires `ONBOARDING_OPERATOR_PIN`. +- It updates the latest `Internal reviews` row for the supplied `applicationId`, or creates one if missing. +- Accepted/checklist fields include: + - `reviewStatus`; + - `assignedOwner`; + - `legalCompanyCheck`; + - `websiteDomainCheck`; + - `publicLinksCheck`; + - `messagePurposeExamplesCheck`; + - `consentOptOutCheck`; + - `kycTrustHubCheck`; + - `smsFallbackRcBundleCheck`; + - `phonePreviewReadiness`; + - `nextAction`; + - `notes`; + - `sourceStatus`. +- If `partAAccepted = true` or `reviewStatus = accepted`, it reuses the existing internal status path to set: + - `registrationStatus = part_a_accepted`; + - `partAStatus = part_a_accepted`; + - `nextActionOwner = RightOnQ`; + - `nextActionNote = Prepare the phone name and logo preview` unless supplied. +- Because it reuses the status path, the existing status-event and communications queue behaviour should still apply. + +Important caveat: + +- Positive live proof still depends on `ONBOARDING_OPERATOR_PIN` being configured or an internal wrapper being built. +- Safe live proof completed against version `17`: an unauthorised `updateInternalReview` call for `ROQ-RCS-TEST-REVIEW-202605142008` returned `ONBOARDING_OPERATOR_PIN is not configured`. +- Spreadsheet readback after that rejected call showed the `Internal reviews` row stayed `pending_review` and the `Applications` row stayed `part_a_submitted`, with `Trust Hub status = not_started`. + +### Slice 6F - Local Operator Review Wrapper + +Status: implemented by RCS-Twilio-4 on Thursday 14 May 2026. + +Purpose: + +- give RightOnQ a repeatable local command for updating `Internal reviews`; +- avoid hand-building curl payloads for every internal review; +- keep the operator PIN out of the public static app, repo, and Sheet audit JSON; +- create a small contract that can later be reused by a proper internal admin UI. + +Implemented behaviour: + +- repo-owned Node wrapper at `rcs-registration/tools/operator-review.mjs`; +- reads `RCS_ONBOARDING_OPERATOR_PIN` from the local environment; +- sends `action = updateInternalReview` to the deployed Apps Script endpoint; +- supports `--dry-run` so operators can inspect the payload without sending it; +- supports checklist fields and `--part-a-accepted` to trigger the guarded Part A acceptance path. + +Verification: + +- `node --check rcs-registration/tools/operator-review.mjs` passed. +- `--dry-run` printed the expected `updateInternalReview` payload without an operator PIN. +- Running without `RCS_ONBOARDING_OPERATOR_PIN` failed locally before sending. +- Running with a dummy local PIN reached Apps Script and returned `ONBOARDING_OPERATOR_PIN is not configured`. +- Spreadsheet readback after the dummy live attempt showed no mutation: the review row stayed `pending_review`, the application stayed `part_a_submitted`, and `Trust Hub status` stayed `not_started`. + +Important caveat: + +- The wrapper does not configure the Apps Script-side `ONBOARDING_OPERATOR_PIN`. +- Positive live proof still requires that script property to be configured. + +### Slice 6G - Guarded Operator Snapshot Readback + +Status: implemented and deployed by RCS-Twilio-4 on Thursday 14 May 2026. + +Purpose: + +- let RightOnQ inspect one application's operational state before and after an operator action; +- avoid relying on manual Sheet scanning for every review; +- keep the client-facing status endpoint limited and token-safe; +- give the future internal admin UI a clean readback contract. + +Implemented behaviour: + +- Apps Script supports guarded `action = getOperatorSnapshot`; +- the action requires `ONBOARDING_OPERATOR_PIN`; +- response includes a redacted application summary, latest internal review, recent status events, and queued communications; +- `Private application token` and raw `Submission JSON` are not returned in the operator snapshot; +- local wrapper at `rcs-registration/tools/operator-status.mjs` sends the guarded readback request using `RCS_ONBOARDING_OPERATOR_PIN`. + +Verification: + +- `Code.gs` syntax check passed. +- `node --check rcs-registration/tools/operator-status.mjs` passed. +- `operator-status.mjs --dry-run` printed the expected `getOperatorSnapshot` payload without an operator PIN. +- `git diff --check` passed for the scoped files. +- Apps Script version `18` was created and deployed to the existing web app deployment. +- A dummy live operator-status request reached Apps Script and returned `ONBOARDING_OPERATOR_PIN is not configured`. +- Spreadsheet readback after the dummy live attempt showed no mutation. + +Important caveat: + +- Positive live proof still requires the Apps Script-side `ONBOARDING_OPERATOR_PIN` script property to be configured. + +### Slice 6H - Local Private Application Link Wrapper + +Status: implemented by RCS-Twilio-4 on Thursday 14 May 2026. + +Purpose: + +- give RightOnQ a repeatable local command for turning a qualified CRM/outreach handoff into a private application link; +- avoid hand-building `createApplicationDraft` curl payloads; +- keep the create PIN out of the public static app, repo, and Sheet audit JSON; +- preserve the source-of-truth split: CRM qualifies the lead, onboarding creates the application record/link. + +Implemented behaviour: + +- repo-owned Node wrapper at `rcs-registration/tools/operator-create-application.mjs`; +- reads `RCS_ONBOARDING_CREATE_PIN` from the local environment; +- sends `action = createApplicationDraft` to the deployed Apps Script endpoint; +- supports CRM, company, contact, campaign, package, and handoff context fields; +- supports `--dry-run` so operators can inspect the payload without sending it; +- successful live runs return the private application link for that specific client/application. + +Verification: + +- `node --check rcs-registration/tools/operator-create-application.mjs` passed. +- `--dry-run` printed the expected `createApplicationDraft` payload without a create PIN. +- Running without `RCS_ONBOARDING_CREATE_PIN` failed locally before sending. +- Running with a dummy local create PIN reached Apps Script and returned `ONBOARDING_CREATE_PIN is not configured`. +- Spreadsheet readback after the dummy live attempt showed no new `ROQ-RCS-TEST-CREATE-WRAPPER-202605142032` row in `Applications`. + +Important caveat: + +- The wrapper does not configure the Apps Script-side `ONBOARDING_CREATE_PIN`. +- Positive live proof still requires that script property to be configured. + +### Slice 6I - Isa Bell Reply Integration + +Status: implemented and deployed by RCS-Twilio-4 on Thursday 14 May 2026. + +Purpose: + +- incorporate Isa Bell's Twilio reply into the source-of-truth build plan; +- remove stale `pending Isa` assumptions from future build decisions; +- align future internal checklist rows with the now-known KYC stance. + +Implemented behaviour: + +- update Trust Hub/RC Bundle assumptions: + - Secondary Compliance Profile per UK limited-company end client; + - UK long-code RC Bundle remains a separate number-compliance lane; + - one required primary authorised representative; + - optional second representative only; + - ID evidence is exception-only, not upfront; + - ID/document evidence must not enter the static app or Sheet path; +- change future `Internal reviews` KYC default from `pending_isa_reply` to `pending_trust_hub_review`. + +Verification: + +- `Code.gs` syntax check passed. +- `git diff --check` passed for the scoped files. +- Apps Script version `19` was created and deployed to the existing web app deployment. +- No live test submission was created for this small default-value change; existing `pending_isa_reply` test rows remain historical proof rows. + +Important caveat: + +- Existing test rows with `pending_isa_reply` are historical proof rows and do not need mutation unless Bugs asks for cleanup. + +### Slice 6J - Internal Trust Hub / RC Bundle Tracking Rows + +Status: implemented and deployed by RCS-Twilio-4 on Thursday 14 May 2026. + +Purpose: + +- move Trust Hub and UK RC Bundle tracking from planning-only into the internal Sheet/backend layer; +- keep KYC/number-compliance work separate from the public Part A form; +- make operator snapshots show the current Trust Hub and UK RC Bundle state for each application; +- avoid collecting or storing raw ID evidence. + +Implemented behaviour: + +- Apps Script defines internal `Trust Hub KYC` headers. +- Apps Script defines internal `UK RC bundles` headers. +- Future Part A submissions append one internal row to each tracking tab. +- Guarded operator snapshots include the latest Trust Hub KYC row and latest UK RC Bundle row. +- Future rows store IDs, statuses, exception codes, rejection summaries, and notes only. + +Verification: + +- `Code.gs` syntax check passed. +- `git diff --check` passed for the scoped files. +- Local mocked-Sheet proof confirmed `Trust Hub KYC` row length matches headers. +- Local mocked-Sheet proof confirmed `UK RC bundles` row length matches headers. +- Apps Script version `20` was created and deployed to the existing web app deployment. +- No live Part A submission was created for this tracking-structure slice, to avoid another Sheet/email proof row. + +Important caveat: + +- Existing applications/test rows will not be backfilled automatically. +- This slice does not call Twilio APIs or submit compliance profiles/bundles. +- This slice does not add sensitive ID upload fields. + +### Slice 6K - Operator Tool Usage Notes + +Status: implemented by RCS-Twilio-4 on Thursday 14 May 2026. + +Purpose: + +- make the local operator workflow usable without reading tool source code; +- give future agents/operators a clear safe order of operations; +- keep PIN handling and private-link handling explicit. + +Implemented behaviour: + +- add `rcs-registration/tools/README.md`; +- document all three local operator tools: + - `operator-create-application.mjs`; + - `operator-status.mjs`; + - `operator-review.mjs`; +- include dry-run examples before live examples; +- explain local environment PIN variables without storing any real PIN; +- list expected results and common failure messages. + +Verification: + +- `operator-create-application.mjs` dry-run example produced the expected `createApplicationDraft` payload. +- `operator-status.mjs` dry-run example produced the expected `getOperatorSnapshot` payload. +- `operator-review.mjs` dry-run example produced the expected `updateInternalReview` payload. +- `git diff --check` passed for the scoped documentation files. + +Important caveat: + +- This is documentation only. +- It does not configure Apps Script PINs. + +### Slice 6L - Positive Operator PIN Proof + +Status: completed by Bugs and RCS-Twilio-4 on Thursday 14 May 2026 using Bugs' normal Mac Terminal. + +Purpose: + +- prove the real Apps Script-side `ONBOARDING_CREATE_PIN` and `ONBOARDING_OPERATOR_PIN` properties work; +- prove the local operator toolchain can create, read, approve, and read back an application without exposing PINs in chat/repo files; +- verify the status-event and communication-queue side effects of Part A acceptance. + +What happened: + +- Bugs set both Script Properties in Apps Script Project Settings: + - `ONBOARDING_CREATE_PIN`; + - `ONBOARDING_OPERATOR_PIN`. +- An initial long pasted command was mangled by Terminal and produced `Unknown option: --`; this was a paste issue, not a PIN or backend issue. +- A read-only terminal refresh then proved `operator-status.mjs` could use `ONBOARDING_OPERATOR_PIN` successfully. +- Bugs then ran `operator-review.mjs` successfully against the created test application. + +Test application: + +- `ROQ-RCS-TEST-POSITIVE-20260514211204` + +Verified result: + +- `operator-create-application.mjs` created the private application record: + - `registrationStatus = application_created`; + - `partAStatus = draft`; + - private application link was present in the returned result but was not pasted into the docs. +- `operator-status.mjs` read the guarded operator snapshot successfully. +- `operator-review.mjs` accepted Part A: + - `reviewStatus = accepted`; + - `partAAccepted = true`; + - `registrationStatus = part_a_accepted`; + - `partAStatus = part_a_accepted`. +- Final operator snapshot showed: + - `Applications` row moved to `part_a_accepted`; + - `Next action owner = RightOnQ`; + - `Next action note = Prepare the phone name and logo preview.`; + - latest `Internal reviews` row had all supplied checks and `Phone preview readiness = ready`; + - one `Status events` row was present for `internal_review_completed`; + - one `Communications` row was queued with code `part_a_accepted`; + - `Submission JSON` in the operator snapshot was redacted. + +Expected limitation: + +- `Trust Hub KYC` and `UK RC bundles` were empty for this test because it created a private application link but did not submit Part A through the public form. Those rows are created on Part A submission. + +Security note: + +- PINs were not committed or written to repo files. +- The final proof used Bugs' local Terminal environment variables, then unset them. + +### Slice 6M - Public Part A Submission Proof + +Goal: + +- prove the customer-facing/public Part A submission path after Apps Script version `20`; +- confirm that a real Part A submission through a private application link creates the internal Trust Hub KYC and UK RC Bundle tracking rows. + +Added helper: + +- `rcs-registration/tools/proof-public-part-a-submit.mjs` + +Helper behaviour: + +- creates a private test application using the existing guarded `createApplicationDraft` action; +- extracts the private application token from the returned link without printing it; +- submits a complete Part A test payload through the normal public submission branch; +- reads the guarded operator snapshot; +- prints a redacted summary only. + +Proof application: + +- `ROQ-RCS-TEST-PUBLIC-PARTA-20260514211901` + +Proof result: + +- private application creation returned: + - `registrationStatus = application_created`; + - `partAStatus = draft`; + - private application link present. +- public Part A submission returned: + - `ok = true`; + - `submissionId = RCS-20260514-PUBLIC-PARTA-PROOF`; + - `registrationStatus = part_a_submitted`; + - `receivedAt = 2026-05-14T21:19:06.317Z`. +- operator snapshot confirmed: + - `Applications.registrationStatus = part_a_submitted`; + - `Applications.partAStatus = part_a_submitted`; + - `Applications.Trust Hub status = not_started`; + - latest `Internal reviews` row exists with `pending_review`; + - latest `Trust Hub KYC` row exists with `Trust Hub status = not_started`; + - latest `UK RC bundles` row exists with `RC bundle status = not_started`; + - `UK RC bundles.Fallback required = to_be_confirmed`; + - `UK RC bundles.Compliance owner = end_business`; + - queued communication code includes `part_a_received`. + +Security: + +- PINs were entered by Bugs in local Terminal and unset after the proof. +- The helper does not print the private token, private application link, create PIN, or operator PIN. + +Outcome: + +- The previous evidence gap is closed. +- The next build slice can safely focus on guarded operator update actions for Trust Hub KYC and UK RC Bundle statuses. + +### Slice 6N - Guarded Trust Hub / RC Bundle Operator Updates + +Goal: + +- let RightOnQ update internal Trust Hub KYC and UK RC Bundle tracking without direct Sheet edits; +- keep the public form free of identity-document collection; +- preserve audit/status-event evidence for manual operator changes. + +Apps Script version: + +- version `21`; +- deployed to existing web app deployment `AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6`. + +Added guarded actions: + +- `updateTrustHubKyc`; +- `updateUkRcBundle`. + +Both actions: + +- require `ONBOARDING_OPERATOR_PIN`; +- reject incorrect PINs; +- update the matching internal tracking row by `Application ID`; +- append/update status evidence without storing raw identity evidence. + +Added local tools: + +- `rcs-registration/tools/operator-trusthub-kyc.mjs`; +- `rcs-registration/tools/operator-rc-bundle.mjs`. + +Live proof application: + +- `ROQ-RCS-TEST-PUBLIC-PARTA-20260514211901` + +Security proof: + +- an incorrect operator PIN was entered first; +- Trust Hub update, RC Bundle update, and status snapshot all returned `Invalid onboarding operator PIN`; +- the correct PIN was then entered and the proof succeeded. + +Correct-PIN proof result: + +- Trust Hub KYC update returned: + - `trustHubStatus = pending_review`; + - `secondaryComplianceProfileSid = BU_TEST_SECONDARY_PROFILE`; + - `evaluationStatus = not_run`; + - `updatedAt = 2026-05-15T07:24:16.476Z`. +- UK RC Bundle update returned: + - `rcBundleStatus = pending_review`; + - `fallbackRequired = yes`; + - `updatedAt = 2026-05-15T07:24:23.739Z`. +- operator snapshot confirmed: + - `Applications.Trust Hub status = pending_review`; + - latest `Trust Hub KYC` row has `Trust Hub status = pending_review`; + - latest `Trust Hub KYC` row has `Secondary compliance profile SID = BU_TEST_SECONDARY_PROFILE`; + - latest `Trust Hub KYC` row has `Business website match status = pending_review`; + - latest `Trust Hub KYC` row has `Evaluation status = not_run`; + - latest `UK RC bundles` row has `RC bundle status = pending_review`; + - latest `UK RC bundles` row has `Fallback required = yes`; + - latest `UK RC bundles` row has `Compliance owner = end_business`; + - `Status events` includes `trust_hub_kyc_updated`; + - `Status events` includes `uk_rc_bundle_updated`; + - `Submission JSON` remains redacted in operator snapshots. + +Outcome: + +- manual Trust Hub and RC Bundle status tracking is now available through guarded local tools; +- no client-facing ID collection was added; +- no raw identity documents, DOB, passport, driving licence, or proof-of-address fields were added to the public form or Sheet workflow. + +### Slice 6O - Evidence Exception Tracking Fields + +Goal: + +- prepare for Twilio-managed evidence exceptions without adding ID uploads to the public form; +- keep RightOnQ's internal record aware of the evidence status; +- store only status/reference data. + +Added to `Trust Hub KYC`: + +- `Evidence collection mode`; +- `Evidence status`; +- `Evidence provider`; +- `Evidence inquiry ID`; +- `Evidence registration ID`; +- `Evidence requested at`; +- `Evidence submitted at`; +- `Evidence approved at`; +- `Evidence rejected at`; +- `Evidence rejection reason`. + +Default for new Part A submissions: + +- `Evidence collection mode = not_required`; +- `Evidence status = not_required`. + +Supported update path: + +- `operator-trusthub-kyc.mjs` can update the evidence fields through guarded `updateTrustHubKyc`. + +Apps Script version: + +- version `22`; +- deployed to existing web app deployment `AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6`. + +Live proof application: + +- `ROQ-RCS-TEST-PUBLIC-PARTA-20260514211901` + +Proof result: + +- `operator-trusthub-kyc.mjs` returned `ok = true`; +- snapshot confirmed: + - `Authorised rep exception code = 18019`; + - `Authorised rep exception action = twilio_managed_evidence_required`; + - `Evidence collection mode = twilio_managed`; + - `Evidence status = requested`; + - `Evidence provider = twilio_compliance_embeddable`; + - `Evidence inquiry ID = inq_TEST_EVIDENCE`; + - `Evidence registration ID = tri_TEST_EVIDENCE`; + - `Evidence requested at = 2026-05-15T08:00:00Z`; + - `KYC internal notes = Evidence exception proof only. No identity evidence stored.` + +Boundary: + +- no passport, driving licence, DOB, proof-of-address, government ID, or raw identity document field was added; +- the current design stores only Twilio/compliance IDs, status values, timestamps, rejection reasons, and operator notes. + +### Slice 7 - Customer Commercial/Payment Entry Page + +Design/build the onboarding page before the RCS form. + +Output: + +- package explanation; +- price/usage/deposit/top-up wording; +- terms acceptance; +- Revolut checkout handoff; +- success route to private application link. + +### Slice 8 - Revolut Sandbox Proof + +Test Revolut flow before committing to implementation. + +Questions: + +- Can RightOnQ create a customer/order/payment in sandbox? +- Can the payment method be saved for future merchant-initiated charge? +- Can the first payment be `£75`? +- Can later top-up charge be initiated? +- What webhook events arrive? +- How are failed payments represented? +- What IDs should be stored? + +### Slice 9 - Twilio Trust Hub / Subaccount / Usage Tracking Fields + +Add internal Twilio compliance, runtime setup, and usage tracking fields. + +Output: + +- secondary compliance profile SID; +- Trust Hub status; +- Trust Hub rejection/evaluation summary; +- Trust Hub error code/detail; +- authorised representative exception code/action; +- primary authorised representative tracking fields; +- optional second authorised representative tracking fields; +- UK RC Bundle SID/status/rejection fields; +- subaccount SID; +- setup status; +- registration/provider reference; +- usage pull plan; +- manual pause flag; +- usage balance reconciliation fields. + +### Slice 10 - Website Integration + +Decide how the public website introduces this flow. + +Output: + +- public RCS service page; +- call-to-action into onboarding; +- clear expectations before payment/form; +- read-only context file for website agents to understand this workflow. + +## Open Questions + +- Exact wording of the `Local Time Only` package. +- Whether registration assistance has a separate setup fee or is bundled. +- Exact first payment amount. +- Exact prepaid credit/top-up threshold. +- Whether auto top-up is mandatory or optional. +- Whether clients can use Direct Debit later. +- Whether Revolut subscriptions are reliable enough in sandbox for the monthly base fee. +- How private application links are generated and revoked. +- Whether Google Sheets remains the source of truth beyond pilot. +- Who inside RightOnQ manually approves each status transition. +- Exact live Twilio Trust Hub Secondary Business policy requirements for UK clients. +- Whether representative 1 should be split into first/last/email/mobile/title/job-position in the customer form or captured internally later. +- Whether optional rep 2 should be added later or kept as manual follow-up. +- Which secure/Twilio-managed route will handle exception-only identity evidence if Twilio cannot digitally verify a representative. + +## Update Rules For Future Agents + +When working on RCS onboarding: + +1. Read this file. +2. Read the latest `RCS_TWILIO_*_HANDOVER_*.md`. +3. Update this file when product decisions, workflow, statuses, schema, payment assumptions, or build slices change. +4. Keep implementation notes brief here; put detailed local state and dirty-checkout warnings in the agent handover diary. +5. Do not silently pivot from Revolut-first to Stripe-first without recording the reason. diff --git a/rcs-registration/RCS_TWILIO_1_HANDOVER_2026-05-06.md b/rcs-registration/RCS_TWILIO_1_HANDOVER_2026-05-06.md new file mode 100644 index 0000000..99605a4 --- /dev/null +++ b/rcs-registration/RCS_TWILIO_1_HANDOVER_2026-05-06.md @@ -0,0 +1,1265 @@ +# RCS-Twilio-1 Handover Diary + +Started: Wednesday 6 May 2026 +Last updated: Tuesday 12 May 2026, morning BST +Project: RightOnQ RCS Registration Studio +Primary working file: `/Users/macpro/rightonq-code.github.io/rcs-registration/index.html` +Current local browser URL: `file:///Users/macpro/rightonq-code.github.io/rcs-registration/index.html` +Git branch: `rcs-registration-part-a-b-20260507` +Latest pushed app commit before this handover: `f04c22f Refine RCS registration Part B flow` +Handover/GitHub plan commit: `224e92d Update RCS handover with GitHub and hosting plan` +Initial RCS form commit: `4893751 Add standalone RCS registration form` + +## Morning Sync - Tuesday 12 May 2026 + +This is the newest state for agents working around the RightOnQ website, RCS registration app, Twilio sender setup, and Part B storyboard work. It supersedes older notes below where they conflict. + +### Current Branch / Files + +Working branch: + +- `rcs-registration-part-a-b-20260507` + +Files refreshed in this morning sync: + +- `/Users/macpro/rightonq-code.github.io/rcs-registration/RCS_TWILIO_1_HANDOVER_2026-05-06.md` +- `/Users/macpro/rightonq-code.github.io/rcs-registration/index.html` +- `/Users/macpro/rightonq-code.github.io/rcs-registration/google-apps-script/Code.gs` + +### File And Preview Protocol - Do Not Skip + +This project contains more than one `index.html`. Always verify the file path before editing. + +### Golden Rule For Working With Adam + +Do not rush ahead from half-formed ideas into edits. + +Adam wants a collaborative working relationship, not a cold or passive agent. It is fine to inspect, think, suggest, and bring good ideas. But before changing product flow, wording, layout, files, commits, pushes, or anything that could affect the project state: + +1. stop; +2. explain the exact proposed change; +3. discuss the reasoning in plain language; +4. wait for Adam's approval; +5. then edit only the approved scope. + +This is especially important because the RCS work is commercially important and multiple agents may be active in nearby files. Unapproved changes, wrong preview URLs, wrong files, or accidental overwrites create anxiety and can cost hours or days of recovery work. The safest rhythm is: inspect first, propose clearly, wait for confirmation, then make the agreed change and verify it on the correct preview URL. + +Correct RCS application file: + +- `/Users/macpro/rightonq-code.github.io/rcs-registration/index.html` + +Main website file, not the RCS app: + +- `/Users/macpro/rightonq-code.github.io/index.html` + +Correct repo root for preview server: + +- `/Users/macpro/rightonq-code.github.io` + +Correct local preview command: + +```bash +cd /Users/macpro/rightonq-code.github.io +python3 -m http.server 8902 +``` + +Correct local preview URL: + +- `http://localhost:8902/rcs-registration/index.html` + +Avoid using a server started from inside `/Users/macpro/rightonq-code.github.io/rcs-registration` for visual review. It can make root-relative assets such as `/images/...` resolve incorrectly and can make the page look subtly wrong. If the browser shows a `file://` URL or a localhost URL that does not include `/rcs-registration/index.html`, stop and correct the preview before judging the design. + +Before editing: + +- confirm branch with `git branch --show-current`; +- confirm the target file path is `rcs-registration/index.html`; +- check `git status --short` and note unrelated dirty files; +- do not stage root `index.html`, legal pages, or future-amendment notes unless the user explicitly asks. + +Before saving work as a commit: + +- run the inline script syntax check for `rcs-registration/index.html`; +- run `git diff --check` on the exact files being committed; +- check `git diff --cached --name-only` before committing; +- commit only the files in the approved scope; +- do not push experimental layout work until the user has seen the correct `http://localhost:8902/rcs-registration/index.html` preview and approved it. + +Known unrelated local work at the time of this sync: + +- root `/Users/macpro/rightonq-code.github.io/index.html` is modified and should still be treated as unrelated to the RCS registration app unless the user explicitly asks to work on the main website. +- root `privacy.html` and `terms.html` are modified on this branch because the cleaned legal-page wording from `main` was synced into the RCS branch. +- untracked future-amendment notes exist at repo root and should not be staged by accident. + +### Twilio Console State Learned On 11 May + +The user inspected the live Twilio Console for the existing RightOnQ RCS sender draft. Do not repeat account SIDs, sender SIDs, agent IDs, billing details, private device numbers, or uploaded Twilio asset URLs in shared notes or public docs. + +Confirmed live-console facts: + +- RCS is available in the Twilio account. +- An existing `RightOnQ` RCS sender draft exists and is still `Not Submitted`. +- Tabs visible for the sender: `Public details`, `Test`, `Configuration`, `Compliance registration`. +- Public details can be edited and re-saved while the sender is still not submitted. +- Compliance registration was blocked until required public profile fields were completed. +- Test tab exists and offers adding an RCS-compatible test device. +- Configuration tab asks for webhook URLs, fallback URL, status callback URL, and assigned Messaging Service. These were inspected but not configured. +- No compliance submission, live launch submission, production messaging, or webhook send path has been approved yet. + +Current RightOnQ Twilio Public Details values used/approved in the live console: + +- Sender display name: `RightOnQ™` +- Use case: `Promotional` +- Description: `See how branded RCS messages can make two-way conversations clearer from the first hello.` +- Accent colour: `#1763ba` +- Contact type: `Email` +- Primary email: `adam@rightonq.co.uk` +- Email label: `Support` +- Privacy policy: `https://www.rightonq.co.uk/privacy/` +- Terms of service: `https://www.rightonq.co.uk/terms/` + +Important description lesson: + +- Twilio's visible helper says the description should include how users interact with the sender. +- The earlier storyboard line, `The RCS software layer for effective business messaging.`, is elegant brand/profile copy, but the final Twilio description is better because it describes the experience and remains under 100 characters. +- The Part A app now treats this as a `Public profile description` with a 100-character maximum, rather than a loose 500-character sender description. + +Current local upload-ready asset files created for the Twilio public profile: + +- Logo: `/Users/macpro/Downloads/design_handoff_rcs_storyboard/assets/rightonq-rcs-logo-upload-224-padded.png` + - verified as 224 x 224 PNG, about 21 KB. + - this is the correct padded upload file, not the older comparison/review sheet. +- Banner: `/Users/macpro/Downloads/design_handoff_rcs_storyboard/assets/rightonq-rcs-banner-upload-1440x448.jpg` + - verified as 1440 x 448 JPG, about 5.1 KB. + - this was copied to an obvious final upload filename so it is easy for the user to find from Finder/upload dialogs. + +Practical file-delivery rule learned: + +- When the user needs to upload an asset manually, create an obvious final file in the working folder, verify dimensions and size, then give that exact path. Avoid sending the user to ambiguous source/review filenames. + +Logo preview rule learned from real Twilio test device: + +- Do not judge the sender logo only from the upload/crop preview. Test it at real inbox size on both light and dark phone screens before submission. +- The RightOnQ dark logo tile is acceptable for the pilot, especially on light mode. It is not worth over-tuning now. +- For client applications, logo contrast may matter more than the artwork looking perfect at full size. A client's logo should be checked in the message list/inbox view before final submission. +- Twilio's test-device flow is commercially useful: RightOnQ can nominate an internal RCS-capable phone, view the client's sender profile in a real inbox, and adjust logo/background/size before submission. This is part of the value of a managed registration service, because many clients will not know how to judge these details from provider upload screens alone. + +### Part A App Update From This Sync + +`rcs-registration/index.html` has been updated so the existing `senderDescription` field now matches the live Twilio learning: + +- label changed from `Sender description` to `Public profile description`; +- helper now explains it may appear in the messaging app profile and must be clear, factual, and under 100 characters; +- `maxlength` changed from `500` to `100`; +- character counter changed from `0 / 500` to `0 / 100`; +- review label changed to `Public profile description`; +- automated draft descriptions were shortened so they fit the 100-character profile field. + +The longer reviewer/compliance explanation remains separate as `Message use case description`; do not collapse the public profile description and longer use-case explanation into one field. + +### Apps Script Intake Update From This Sync + +`rcs-registration/google-apps-script/Code.gs` now includes these lines in Adam's notification email for new Part A submissions: + +- `Use case` +- `Public profile description` + +The Google Sheet append order was not changed in this sync to avoid shifting existing spreadsheet columns. The full payload JSON already remains in the appended row, so the public profile description is still preserved in the raw submission data. + +### Twilio Support Email Evidence + +Twilio Support replied to Adam's ticket on 11 May 2026 and confirmed the useful Part B/video assumptions: + +- RightOnQ can produce the RCS verification video on behalf of clients. +- The actual client does not need to appear in the video. +- The video must accurately demonstrate the end-user opt-in and opt-out flows. +- If a client brand is not registered yet, a staged or simulated flow is acceptable, provided the brand identity and user journey are clear. +- Twilio does not currently provide an official sandbox/test agent or official example verification video for this process. +- Twilio said they are happy to review a draft video/storyboard and provide suggestions. + +Adam replied to the ticket to confirm that understanding and keep the guidance in the support record. + +### Current Part B / Storyboard Workspace + +Current Part B design/storyboard workspace: + +- `/Users/macpro/Downloads/design_handoff_rcs_storyboard/storyboard.html` +- preview: `http://localhost:8899/storyboard.html` + +If the preview server is not running: + +```bash +cd /Users/macpro/Downloads/design_handoff_rcs_storyboard +python3 -m http.server 8899 +``` + +Important related files in that workspace: + +- `/Users/macpro/Downloads/design_handoff_rcs_storyboard/RCS_TWILIO_2_HANDOVER_2026-05-11.md` +- `/Users/macpro/Downloads/design_handoff_rcs_storyboard/google-rcs-video-readiness-audit.md` +- `/Users/macpro/Downloads/design_handoff_rcs_storyboard/rightonq-rbm-test-agent-plan.md` +- `/Users/macpro/Downloads/design_handoff_rcs_storyboard/rightonq-part-b-rbm-registration-playbook.md` +- `/Users/macpro/Downloads/design_handoff_rcs_storyboard/rightonq-rbm-use-case-classification-note.md` +- `/Users/macpro/Downloads/design_handoff_rcs_storyboard/rightonq-rbm-google-submission-narrative.md` +- `/Users/macpro/Downloads/design_handoff_rcs_storyboard/rightonq-twilio-public-details-fill-sheet.md` + +Current direction: + +- Twilio Console is the current application/control-plane route for the RightOnQ pilot. +- Google/RBM requirements still sit underneath the process and should guide evidence quality. +- Direct Google RBM API/test-agent work is useful as technical background, but the current operational route is Twilio first. +- Do not spend on AI video yet. +- Do not add runnable sender code yet. +- Do not put secrets in files. +- Do not send real messages until Twilio sender/test-device credentials and explicit approval exist. +- Do not assume Twilio public details completion means Compliance Registration or carrier approval has happened. + +New Part B workflow idea from 12 May test-device learning: + +- The first practical Part B step should likely be a real-device sender-profile check before video production. +- After Part A is received and RightOnQ has the client's logo/brand details, RightOnQ can upload/test the client's sender profile in Twilio, nominate an internal RCS-capable phone, and check the logo at real inbox size. +- Twilio's Public Details phone preview appears useful but may not be accurate for final inbox thumbnail scale. The real phone is the truth. +- RightOnQ should consider keeping a small set of test phones, including at least one Android device and more than one display mode/device type, to check thumbnail scale and contrast before formal submission. +- After RightOnQ has adjusted the client's logo/background/size, the client can be invited to become a tester and receive the test sender/profile on their own preferred phone. +- Because this test route is outbound/test-only, the client may not be able to reply in-message with approval. The Part B workflow should therefore include an explicit checkbox/sign-off such as "I have viewed the sender profile/logo on my phone and approve it for the review video/submission." +- This should sit at the beginning of Part B, after Part A intake/review and before the final review video is prepared. +- This is a strong managed-service value point: RightOnQ is not merely collecting a logo; it is checking how the sender identity actually appears on real devices and reducing the risk of a poor-looking or rejected submission. + +### Use Case / Category Decision + +Current first-launch category: + +- `Promotional` + +Reason: + +- The first RightOnQ sender is for people/businesses exploring RCS, seeing examples, and receiving follow-up. +- It should not be labelled `Multi-use` for the first submission unless the evidence genuinely supports both promotional and transactional use. +- Future client registration/application updates may be transactional, but that is a later service pattern and should not be mixed into this first RightOnQ pilot sender unless deliberately approved. + +### Legal Pages / Brand Notes + +Privacy and Terms pages exist and have been synced into the RCS branch with public provider names removed. They are suitable for continued RCS/RBM preparation, subject to final human/legal review before formal submission. + +Use these public URLs for the RightOnQ pilot unless a later legal review changes them: + +- `https://www.rightonq.co.uk/privacy/` +- `https://www.rightonq.co.uk/terms/` + +Brand/trademark note: + +- RightOnQ is currently treated with `™` in the Twilio draft sender name. +- Do not change to `®` in live submission material unless the trademark registration position has been confirmed by the human/account owner before submission. + +### Notes For RightOnQ.co.uk-Web-4 + +The RCS registration app is still a standalone mini-application and should not be merged into the main website homepage by accident. + +Useful website-design context: + +- RightOnQ's public website, legal pages, and any RCS registration entry point should tell the same basic story: RightOnQ helps businesses understand and use RCS for clearer two-way conversations. +- Avoid publicly naming implementation providers unless there is a deliberate reason. +- Do not imply RCS is already live for public traffic; the Twilio sender is still a draft/not-submitted sender. +- If the main site later links to the RCS form, link out to the standalone registration path/app rather than embedding the whole workflow into the homepage. +- If adding public opt-in copy for the RightOnQ pilot, align it with the storyboard idea: the person asks to receive RightOnQ RCS examples and follow-up messages and can reply STOP to cancel / HELP for help. + +## End Of Day 2 Diary - Thursday 7 May 2026, 20:25 BST + +This section is the current state of play and supersedes older notes below where they conflict. Earlier notes are retained for history because they explain why the form took its current shape. + +### Recovery Status + +The RCS registration work is safely pushed to GitHub. + +Repository: + +- `rightonq-code/rightonq-code.github.io` + +Branch: + +- `rcs-registration-part-a-b-20260507` + +Latest confirmed app commit before this handover: + +- `f04c22f Refine RCS registration Part B flow` + +Files confirmed on GitHub by a second agent: + +- `rcs-registration/index.html` +- `images/rightonq-landscape-glow-logo.png` + +The Chrome/GitHub verification agent confirmed: + +- the branch exists, +- commit `f04c22f` is the latest visible commit, +- `rcs-registration/index.html` is visible at 4,669 lines / 176 KB, +- `images/rightonq-landscape-glow-logo.png` is visible at approximately 2 MB. + +If this computer, Codex Desktop, or the current context window fails, another agent can recover the current RCS registration work from that branch. + +Important caveat: + +- The root site file `/Users/macpro/rightonq-code.github.io/index.html` is still locally modified and unrelated. +- It was deliberately not staged, committed, or pushed as part of the RCS work. +- Continue to avoid staging it unless the user explicitly asks to work on the main website. + +### Current Local / Git Status At Handover + +Working directory: + +- `/Users/macpro/rightonq-code.github.io` + +Current branch: + +- `rcs-registration-part-a-b-20260507` + +Expected status after the last push: + +- branch is up to date with `origin/rcs-registration-part-a-b-20260507`; +- only unrelated root `index.html` remains modified locally. + +Commands used repeatedly for verification: + +```bash +node -e "const fs=require('fs'); const html=fs.readFileSync('rcs-registration/index.html','utf8'); const match=html.match(/ + + diff --git a/rcs-registration/google-apps-script/.clasp.json b/rcs-registration/google-apps-script/.clasp.json new file mode 100644 index 0000000..81b4a8e --- /dev/null +++ b/rcs-registration/google-apps-script/.clasp.json @@ -0,0 +1,16 @@ +{ + "scriptId": "1RUuIglGVcVpNSveeXlzw6O0wJ_A5QTtGCHwRMrJoUSSiyZ0TD_DD9ad8", + "rootDir": "", + "scriptExtensions": [ + ".js", + ".gs" + ], + "htmlExtensions": [ + ".html" + ], + "jsonExtensions": [ + ".json" + ], + "filePushOrder": [], + "skipSubdirectories": false +} \ No newline at end of file diff --git a/rcs-registration/google-apps-script/Code.gs b/rcs-registration/google-apps-script/Code.gs new file mode 100644 index 0000000..c0c7abf --- /dev/null +++ b/rcs-registration/google-apps-script/Code.gs @@ -0,0 +1,1768 @@ +const SPREADSHEET_ID = "1_C85rMaDWS0-VnXbtYQzRBS1trgN8kFf4hAnHfT3R-0"; +const SHEET_NAME = "Part A submissions"; +const APPLICATIONS_SHEET_NAME = "Applications"; +const PART_B_APPROVALS_SHEET_NAME = "Part B approvals"; +const PART_B_VIDEO_APPROVALS_SHEET_NAME = "Part B video approvals"; +const STATUS_EVENTS_SHEET_NAME = "Status events"; +const COMMUNICATIONS_SHEET_NAME = "Communications"; +const INTERNAL_REVIEWS_SHEET_NAME = "Internal reviews"; +const TRUST_HUB_KYC_SHEET_NAME = "Trust Hub KYC"; +const UK_RC_BUNDLES_SHEET_NAME = "UK RC bundles"; +const PUBLIC_FORM_URL = "https://rightonq-code.github.io/rcs-registration/index.html"; +const NOTIFY_EMAIL = "adam@rightonq.co.uk"; +const APPLICATION_HEADERS = [ + "Application ID", + "Client ID", + "CRM company ID", + "CRM deal ID", + "CRM source record URL", + "Private application token", + "Client name", + "Legal business name", + "Trading name", + "Primary contact name", + "Primary contact email", + "Primary contact phone", + "Campaign code", + "Message code", + "Qualified use case", + "Package interest", + "Handoff date", + "Sales context", + "Package name", + "Registration status", + "Billing status", + "Part A status", + "Part B status", + "Twilio status", + "Trust Hub status", + "Provider status", + "Internal owner", + "Created at", + "Updated at", + "Last client action at", + "Last internal action at", + "Next action owner", + "Next action note", + "Internal notes" +]; +const PART_B_APPROVAL_HEADERS = [ + "Received at", + "Application ID", + "Stage", + "Decision", + "Tester invite received", + "Name/logo decision", + "Issue categories", + "Issue notes", + "Registration status", + "Part B status", + "Submission JSON", + "Last updated" +]; +const PART_B_VIDEO_APPROVAL_HEADERS = [ + "Received at", + "Application ID", + "Stage", + "Decision", + "Approval checklist", + "Changes requested", + "Change notes", + "Registration status", + "Part B status", + "Submission JSON", + "Last updated" +]; +const STATUS_EVENT_HEADERS = [ + "Received at", + "Application ID", + "Event type", + "Previous registration status", + "New registration status", + "Previous Part A status", + "New Part A status", + "Previous Part B status", + "New Part B status", + "Billing status", + "Twilio status", + "Trust Hub status", + "Provider status", + "Next action owner", + "Next action note", + "Internal owner", + "Internal notes", + "Changed by", + "Source", + "Submission JSON", + "Last updated" +]; +const COMMUNICATION_HEADERS = [ + "Created at", + "Application ID", + "Communication code", + "Audience", + "Recipient email", + "Recipient name", + "Subject", + "Status", + "Trigger status", + "Send method", + "Body", + "Related event", + "Last updated" +]; +const INTERNAL_REVIEW_HEADERS = [ + "Created at", + "Application ID", + "Review status", + "Assigned owner", + "Legal/company check", + "Website/domain check", + "Public links check", + "Message purpose/examples check", + "Consent/opt-out check", + "KYC/Trust Hub check", + "SMS fallback/RC bundle check", + "Phone preview readiness", + "Next action", + "Notes", + "Source status", + "Last updated" +]; +const TRUST_HUB_KYC_HEADERS = [ + "Created at", + "Application ID", + "Client ID", + "Primary customer profile SID", + "Secondary compliance profile SID", + "Trust Hub policy SID", + "Trust Hub profile friendly name", + "Trust Hub status", + "Trust Hub status updated at", + "Trust Hub status callback configured", + "Trust Hub rejection reason", + "Trust Hub error code", + "Trust Hub error detail", + "Business identity", + "Business type", + "Business industry", + "Business registration identifier", + "Business registration number", + "Business regions of operation", + "Business website match status", + "Address SID", + "Address validation status", + "Supporting document SID", + "Business info end user SID", + "Authorised rep 1 end user SID", + "Authorised rep 2 end user SID", + "Authorised rep 1 validation status", + "Authorised rep 2 validation status", + "Authorised rep exception code", + "Authorised rep exception action", + "Evidence collection mode", + "Evidence status", + "Evidence provider", + "Evidence inquiry ID", + "Evidence registration ID", + "Evidence requested at", + "Evidence submitted at", + "Evidence approved at", + "Evidence rejected at", + "Evidence rejection reason", + "Primary profile assignment status", + "Business info assignment status", + "Rep 1 assignment status", + "Rep 2 assignment status", + "Address assignment status", + "Evaluation status", + "Evaluation last run at", + "Evaluation error summary", + "Channel endpoint assignment status", + "Phone number SID", + "KYC internal notes", + "Last updated" +]; +const UK_RC_BUNDLE_HEADERS = [ + "Created at", + "Application ID", + "Client ID", + "RC bundle SID", + "RC bundle status", + "RC bundle status updated at", + "RC bundle rejection reason", + "RC bundle error code", + "RC bundle error detail", + "End business legal name", + "Business registration number", + "Number type", + "Phone number SID", + "Phone number", + "Phone number assignment status", + "Address SID", + "Supporting document SID", + "Compliance owner", + "Fallback required", + "Internal notes", + "Last updated" +]; +const REGISTRATION_STATUS_ORDER = [ + "draft", + "application_created", + "part_a_submitted", + "part_a_internal_review", + "part_a_changes_needed", + "part_a_accepted", + "phone_preview_sent", + "name_logo_approved", + "name_logo_changes_requested", + "video_preparing", + "video_ready_for_review", + "video_approved", + "video_changes_requested", + "registration_submitted", + "provider_review", + "provider_changes_requested", + "approved", + "live", + "paused_billing", + "paused_operational" +]; + +function doGet(event) { + const applicationId = event && event.parameter && event.parameter.applicationId; + const applicationToken = event && event.parameter && firstValue( + event.parameter.applicationToken, + event.parameter.privateApplicationToken, + event.parameter.private_application_token, + event.parameter.token + ); + if (applicationId || applicationToken) return jsonResponse(getApplicationStatus({ + applicationId: applicationId, + privateApplicationToken: applicationToken + })); + + return jsonResponse({ + ok: true, + service: "RightOnQ RCS Part A Intake", + sheetName: SHEET_NAME + }); +} + +function getApplicationStatus(applicationId) { + const spreadsheet = SpreadsheetApp.openById(SPREADSHEET_ID); + const criteria = typeof applicationId === "object" ? applicationId : { applicationId: applicationId }; + const applicationRecord = findApplicationRecord(spreadsheet, criteria); + if (applicationRecord) { + const recordToken = applicationRecord["Private application token"]; + if (recordToken && String(recordToken) !== String(criteria.privateApplicationToken || "")) { + return { + ok: true, + found: false, + applicationId: criteria.applicationId || "", + registrationStatus: "draft", + partAStatus: "draft" + }; + } + + return { + ok: true, + found: true, + applicationId: applicationRecord["Application ID"] || criteria.applicationId, + registrationStatus: applicationRecord["Registration status"] || "draft", + partAStatus: applicationRecord["Part A status"] || "draft", + partBStatus: applicationRecord["Part B status"] || "", + billingStatus: applicationRecord["Billing status"] || "", + twilioStatus: applicationRecord["Twilio status"] || "", + trustHubStatus: applicationRecord["Trust Hub status"] || "", + providerStatus: applicationRecord["Provider status"] || "", + reviewStatus: "", + partBVideoStatus: "", + nextActionOwner: applicationRecord["Next action owner"] || "", + nextActionNote: applicationRecord["Next action note"] || "", + notes: applicationRecord["Internal notes"] || "", + lastUpdated: serialiseDate(applicationRecord["Updated at"]) + }; + } + + if (criteria.privateApplicationToken) { + return { + ok: true, + found: false, + applicationId: criteria.applicationId || "", + registrationStatus: "draft", + partAStatus: "draft" + }; + } + + const sheet = spreadsheet.getSheetByName(SHEET_NAME); + if (!sheet) throw new Error("Sheet tab not found: " + SHEET_NAME); + + const values = sheet.getDataRange().getValues(); + if (values.length < 2) { + return { + ok: true, + found: false, + applicationId: criteria.applicationId || "", + registrationStatus: "draft", + partAStatus: "draft" + }; + } + + const headers = values[0].map(function(header) { return String(header); }); + const applicationIdColumn = headers.indexOf("Application ID"); + if (applicationIdColumn === -1) throw new Error("Application ID column not found"); + + if (!criteria.applicationId) { + return { + ok: true, + found: false, + applicationId: "", + registrationStatus: "draft", + partAStatus: "draft" + }; + } + + for (let rowIndex = values.length - 1; rowIndex >= 1; rowIndex -= 1) { + const row = values[rowIndex]; + if (String(row[applicationIdColumn]) !== String(criteria.applicationId)) continue; + + return { + ok: true, + found: true, + applicationId: criteria.applicationId, + registrationStatus: readColumn(row, headers, "Registration status") || "part_a_submitted", + partAStatus: readColumn(row, headers, "Part A status") || "part_a_submitted", + reviewStatus: readColumn(row, headers, "Review status"), + partBVideoStatus: readColumn(row, headers, "Part B video status"), + notes: readColumn(row, headers, "Notes"), + lastUpdated: serialiseDate(readColumn(row, headers, "Last updated")) + }; + } + + return { + ok: true, + found: false, + applicationId: criteria.applicationId || "", + registrationStatus: "draft", + partAStatus: "draft" + }; +} + +function doPost(event) { + const lock = LockService.getScriptLock(); + lock.waitLock(10000); + + try { + const payload = parsePayload(event); + const spreadsheet = SpreadsheetApp.openById(SPREADSHEET_ID); + if (payload.action === "createApplicationDraft") { + requireCreatePin(payload); + return jsonResponse(createApplicationDraft(spreadsheet, payload)); + } + if (payload.action === "submitNameLogoApproval") { + return jsonResponse(submitNameLogoApproval(spreadsheet, payload)); + } + if (payload.action === "submitVideoApproval") { + return jsonResponse(submitVideoApproval(spreadsheet, payload)); + } + if (payload.action === "getOperatorSnapshot") { + requireOperatorPin(payload); + return jsonResponse(getOperatorSnapshot(spreadsheet, payload)); + } + if (payload.action === "updateApplicationStatus") { + requireOperatorPin(payload); + return jsonResponse(updateApplicationStatus(spreadsheet, payload)); + } + if (payload.action === "updateInternalReview") { + requireOperatorPin(payload); + return jsonResponse(updateInternalReview(spreadsheet, payload)); + } + if (payload.action === "updateTrustHubKyc") { + requireOperatorPin(payload); + return jsonResponse(updateTrustHubKyc(spreadsheet, payload)); + } + if (payload.action === "updateUkRcBundle") { + requireOperatorPin(payload); + return jsonResponse(updateUkRcBundle(spreadsheet, payload)); + } + + const sheet = spreadsheet.getSheetByName(SHEET_NAME); + if (!sheet) throw new Error("Sheet tab not found: " + SHEET_NAME); + + const now = new Date(); + const submissionId = payload.submissionId || buildSubmissionId(payload, now); + const applicationId = payload.applicationId || buildApplicationId(payload, now); + const registrationStatus = payload.registrationStatus || "part_a_submitted"; + const partAStatus = payload.partAStatus || "part_a_submitted"; + const countries = asList(payload.regions); + const usSelected = countries.indexOf("United States") !== -1 ? "Yes" : "No"; + validateApplicationTokenForSubmission(spreadsheet, applicationId, payload.privateApplicationToken); + + sheet.appendRow([ + now, + safeCell(applicationId), + submissionId, + safeCell(registrationStatus), + safeCell(partAStatus), + "New", + safeCell(firstValue(payload.displayName, payload.tradingName, payload.legalBusinessName)), + safeCell(payload.legalBusinessName), + safeCell(payload.tradingName), + safeCell(payload.displayName), + safeCell(payload.companiesHouseNumber), + safeCell(payload.businessWebsite), + safeCell(payload.primaryContactName), + safeCell(payload.primaryContactEmail), + safeCell(payload.primaryContactPhone), + safeCell(payload.authorizedRepName), + safeCell(payload.authorizedRepEmail), + safeCell(payload.businessIndustry), + safeCell(payload.primaryUseCase), + safeCell(payload.monthlyVolume), + safeCell(countries.join(", ")), + usSelected, + usSelected === "Yes" ? "Not yet agreed" : "Not applicable", + safeCell(payload.organicTraffic), + safeCell(payload.existingSmsTraffic), + safeCell(payload.privacyPolicyUrl), + safeCell(payload.termsUrl), + safeCell(asList(payload.consentRoute).join(", ")), + safeCell(payload.optOutDescription), + JSON.stringify(sanitiseAuditPayload(payload)), + "", + "Not started", + "", + "", + now + ]); + + upsertApplicationRecord(spreadsheet, payload, { + applicationId: applicationId, + registrationStatus: registrationStatus, + partAStatus: partAStatus, + now: now + }); + + queueInternalReview(spreadsheet, { + applicationId: applicationId, + applicationRecord: payload, + triggerStatus: registrationStatus, + now: now + }); + + queueTrustHubKyc(spreadsheet, { + applicationId: applicationId, + applicationRecord: payload, + now: now + }); + + queueUkRcBundle(spreadsheet, { + applicationId: applicationId, + applicationRecord: payload, + now: now + }); + + queueCommunication(spreadsheet, "part_a_received", { + applicationId: applicationId, + applicationRecord: payload, + triggerStatus: registrationStatus, + relatedEvent: "Part A submitted", + now: now + }); + + notifyAdam(payload, submissionId, countries, usSelected); + + return jsonResponse({ + ok: true, + applicationId: applicationId, + submissionId: submissionId, + registrationStatus: registrationStatus, + receivedAt: now.toISOString() + }); + } catch (error) { + return jsonResponse({ + ok: false, + error: error.message || String(error) + }); + } finally { + lock.releaseLock(); + } +} + +function parsePayload(event) { + if (!event || !event.postData || !event.postData.contents) { + throw new Error("Missing POST body"); + } + return JSON.parse(event.postData.contents); +} + +function requireCreatePin(payload) { + const configuredPin = PropertiesService.getScriptProperties().getProperty("ONBOARDING_CREATE_PIN"); + if (!configuredPin) throw new Error("ONBOARDING_CREATE_PIN is not configured"); + if (!payload.createPin || String(payload.createPin) !== String(configuredPin)) { + throw new Error("Invalid onboarding create PIN"); + } +} + +function requireOperatorPin(payload) { + const configuredPin = PropertiesService.getScriptProperties().getProperty("ONBOARDING_OPERATOR_PIN"); + if (!configuredPin) throw new Error("ONBOARDING_OPERATOR_PIN is not configured"); + if (!payload.operatorPin || String(payload.operatorPin) !== String(configuredPin)) { + throw new Error("Invalid onboarding operator PIN"); + } +} + +function createApplicationDraft(spreadsheet, payload) { + const now = new Date(); + const applicationId = payload.applicationId || buildApplicationId(payload, now); + const privateApplicationToken = payload.privateApplicationToken || buildPrivateApplicationToken(); + const registrationStatus = payload.registrationStatus || "application_created"; + const partAStatus = payload.partAStatus || "draft"; + + upsertApplicationRecord(spreadsheet, { + ...payload, + privateApplicationToken: privateApplicationToken + }, { + applicationId: applicationId, + registrationStatus: registrationStatus, + partAStatus: partAStatus, + now: now, + lastClientActionAt: "" + }); + + return { + ok: true, + applicationId: applicationId, + registrationStatus: registrationStatus, + partAStatus: partAStatus, + privateApplicationLink: buildPrivateApplicationLink(applicationId, privateApplicationToken), + createdAt: now.toISOString() + }; +} + +function submitNameLogoApproval(spreadsheet, payload) { + const now = new Date(); + const applicationId = payload.applicationId; + if (!applicationId) throw new Error("Missing application ID"); + + validateApplicationTokenForSubmission(spreadsheet, applicationId, payload.privateApplicationToken); + + const decision = payload.decision || deriveNameLogoDecision(payload); + const issueCategories = asList(payload.issueCategories); + const approved = decision === "approve"; + const registrationStatus = approved ? "name_logo_approved" : "name_logo_changes_requested"; + const partBStatus = registrationStatus; + const nextActionNote = approved + ? "Prepare the RCS application review video." + : "Review name/logo feedback before video work starts."; + + const sheet = getOrCreateSheet(spreadsheet, PART_B_APPROVALS_SHEET_NAME, PART_B_APPROVAL_HEADERS); + sheet.appendRow([ + now, + safeCell(applicationId), + "B2 name/logo approval", + safeCell(decision), + safeCell(payload.testerReceived), + safeCell(payload.nameLogoDecision), + safeCell(issueCategories.join(", ")), + safeCell(payload.issueNotes), + safeCell(registrationStatus), + safeCell(partBStatus), + JSON.stringify(sanitiseAuditPayload(payload)), + now + ]); + + updateApplicationControlFields(spreadsheet, applicationId, { + "Registration status": registrationStatus, + "Part B status": partBStatus, + "Updated at": now, + "Last client action at": now, + "Next action owner": "RightOnQ", + "Next action note": nextActionNote + }); + + queueCommunication(spreadsheet, approved ? "name_logo_approved_received" : "name_logo_feedback_received", { + applicationId: applicationId, + applicationRecord: findApplicationRecord(spreadsheet, { applicationId: applicationId }), + triggerStatus: registrationStatus, + relatedEvent: "B2 name/logo response", + now: now + }); + + notifyNameLogoApproval(payload, decision, issueCategories, registrationStatus); + + return { + ok: true, + applicationId: applicationId, + decision: decision, + registrationStatus: registrationStatus, + partBStatus: partBStatus, + receivedAt: now.toISOString() + }; +} + +function deriveNameLogoDecision(payload) { + if (payload.testerReceived === "not-yet") return "not_yet"; + if (payload.testerReceived === "help") return "help"; + if (payload.nameLogoDecision === "approve") return "approve"; + if (payload.nameLogoDecision === "note") return "note"; + return "issue"; +} + +function submitVideoApproval(spreadsheet, payload) { + const now = new Date(); + const applicationId = payload.applicationId; + if (!applicationId) throw new Error("Missing application ID"); + + validateApplicationTokenForSubmission(spreadsheet, applicationId, payload.privateApplicationToken); + + const decision = payload.decision === "changes_requested" ? "changes_requested" : "approve"; + const approved = decision === "approve"; + const approvalChecklist = asList(payload.approvalChecklist); + const changeCategories = asList(payload.changeCategories); + const registrationStatus = approved ? "video_approved" : "video_changes_requested"; + const partBStatus = registrationStatus; + const nextActionNote = approved + ? "Submit the RCS registration pack." + : "Review requested video changes and prepare an amended review video."; + + const sheet = getOrCreateSheet(spreadsheet, PART_B_VIDEO_APPROVALS_SHEET_NAME, PART_B_VIDEO_APPROVAL_HEADERS); + sheet.appendRow([ + now, + safeCell(applicationId), + "B3 video review", + safeCell(decision), + safeCell(approvalChecklist.join(", ")), + safeCell(changeCategories.join(", ")), + safeCell(payload.changeNotes), + safeCell(registrationStatus), + safeCell(partBStatus), + JSON.stringify(sanitiseAuditPayload(payload)), + now + ]); + + updateApplicationControlFields(spreadsheet, applicationId, { + "Registration status": registrationStatus, + "Part B status": partBStatus, + "Updated at": now, + "Last client action at": now, + "Next action owner": "RightOnQ", + "Next action note": nextActionNote + }); + + queueCommunication(spreadsheet, approved ? "video_approved_received" : "video_changes_received", { + applicationId: applicationId, + applicationRecord: findApplicationRecord(spreadsheet, { applicationId: applicationId }), + triggerStatus: registrationStatus, + relatedEvent: "B3 video response", + now: now + }); + + notifyVideoApproval(payload, decision, approvalChecklist, changeCategories, registrationStatus); + + return { + ok: true, + applicationId: applicationId, + decision: decision, + registrationStatus: registrationStatus, + partBStatus: partBStatus, + receivedAt: now.toISOString() + }; +} + +function updateApplicationStatus(spreadsheet, payload) { + const now = new Date(); + const applicationId = payload.applicationId; + if (!applicationId) throw new Error("Missing application ID"); + + const previous = findApplicationRecord(spreadsheet, { applicationId: applicationId }); + if (!previous) throw new Error("Application ID not found"); + + validateRegistrationStatus(payload.registrationStatus); + const updates = buildStatusUpdates(payload, now); + if (!Object.keys(updates).length) throw new Error("No status fields supplied"); + + updateApplicationControlFields(spreadsheet, applicationId, updates); + + const registrationStatus = finalValue(updates["Registration status"], previous["Registration status"]); + const partAStatus = finalValue(updates["Part A status"], previous["Part A status"]); + const partBStatus = finalValue(updates["Part B status"], previous["Part B status"]); + + const sheet = getOrCreateSheet(spreadsheet, STATUS_EVENTS_SHEET_NAME, STATUS_EVENT_HEADERS); + sheet.appendRow([ + now, + safeCell(applicationId), + safeCell(payload.eventType || "manual_status_update"), + safeCell(previous["Registration status"]), + safeCell(registrationStatus), + safeCell(previous["Part A status"]), + safeCell(partAStatus), + safeCell(previous["Part B status"]), + safeCell(partBStatus), + safeCell(finalValue(updates["Billing status"], previous["Billing status"])), + safeCell(finalValue(updates["Twilio status"], previous["Twilio status"])), + safeCell(finalValue(updates["Trust Hub status"], previous["Trust Hub status"])), + safeCell(finalValue(updates["Provider status"], previous["Provider status"])), + safeCell(finalValue(updates["Next action owner"], previous["Next action owner"])), + safeCell(finalValue(updates["Next action note"], previous["Next action note"])), + safeCell(finalValue(updates["Internal owner"], previous["Internal owner"])), + safeCell(finalValue(updates["Internal notes"], previous["Internal notes"])), + safeCell(firstValue(payload.changedBy, payload.operatorName, "RightOnQ")), + safeCell(firstValue(payload.source, "operator")), + JSON.stringify(sanitiseAuditPayload(payload)), + now + ]); + + queueStatusCommunication(spreadsheet, payload, previous, updates, now); + + return { + ok: true, + applicationId: applicationId, + registrationStatus: registrationStatus, + partAStatus: partAStatus, + partBStatus: partBStatus, + updatedAt: now.toISOString() + }; +} + +function getOperatorSnapshot(spreadsheet, payload) { + const applicationId = payload.applicationId; + if (!applicationId) throw new Error("Missing application ID"); + + const applicationRecord = findApplicationRecord(spreadsheet, { applicationId: applicationId }); + if (!applicationRecord) throw new Error("Application ID not found"); + + return { + ok: true, + applicationId: applicationId, + application: buildOperatorApplicationSummary(applicationRecord), + internalReview: findLatestRecordByApplicationId(spreadsheet, INTERNAL_REVIEWS_SHEET_NAME, applicationId), + trustHubKyc: findLatestRecordByApplicationId(spreadsheet, TRUST_HUB_KYC_SHEET_NAME, applicationId), + ukRcBundle: findLatestRecordByApplicationId(spreadsheet, UK_RC_BUNDLES_SHEET_NAME, applicationId), + recentStatusEvents: findRecentRecordsByApplicationId(spreadsheet, STATUS_EVENTS_SHEET_NAME, applicationId, 5), + queuedCommunications: findRecentRecordsByApplicationId(spreadsheet, COMMUNICATIONS_SHEET_NAME, applicationId, 5), + generatedAt: new Date().toISOString() + }; +} + +function buildOperatorApplicationSummary(record) { + return { + applicationId: record["Application ID"] || "", + clientId: record["Client ID"] || "", + crmCompanyId: record["CRM company ID"] || "", + crmDealId: record["CRM deal ID"] || "", + crmSourceRecordUrl: record["CRM source record URL"] || "", + clientName: record["Client name"] || "", + legalBusinessName: record["Legal business name"] || "", + tradingName: record["Trading name"] || "", + primaryContactName: record["Primary contact name"] || "", + primaryContactEmail: record["Primary contact email"] || "", + primaryContactPhone: record["Primary contact phone"] || "", + campaignCode: record["Campaign code"] || "", + messageCode: record["Message code"] || "", + qualifiedUseCase: record["Qualified use case"] || "", + packageInterest: record["Package interest"] || "", + salesContext: record["Sales context"] || "", + packageName: record["Package name"] || "", + registrationStatus: record["Registration status"] || "", + billingStatus: record["Billing status"] || "", + partAStatus: record["Part A status"] || "", + partBStatus: record["Part B status"] || "", + twilioStatus: record["Twilio status"] || "", + trustHubStatus: record["Trust Hub status"] || "", + providerStatus: record["Provider status"] || "", + internalOwner: record["Internal owner"] || "", + createdAt: serialiseDate(record["Created at"]), + updatedAt: serialiseDate(record["Updated at"]), + lastClientActionAt: serialiseDate(record["Last client action at"]), + lastInternalActionAt: serialiseDate(record["Last internal action at"]), + nextActionOwner: record["Next action owner"] || "", + nextActionNote: record["Next action note"] || "", + internalNotes: record["Internal notes"] || "" + }; +} + +function updateInternalReview(spreadsheet, payload) { + const now = new Date(); + const applicationId = payload.applicationId; + if (!applicationId) throw new Error("Missing application ID"); + + const applicationRecord = findApplicationRecord(spreadsheet, { applicationId: applicationId }); + if (!applicationRecord) throw new Error("Application ID not found"); + + const reviewResult = upsertInternalReviewRecord(spreadsheet, payload, now); + let statusResult = null; + if (payload.partAAccepted === true || payload.partAAccepted === "true" || payload.reviewStatus === "accepted") { + const statusPayload = { + applicationId: applicationId, + registrationStatus: "part_a_accepted", + partAStatus: "part_a_accepted", + nextActionOwner: "RightOnQ", + nextActionNote: firstValue(payload.nextAction, "Prepare the phone name and logo preview."), + eventType: "internal_review_completed", + changedBy: firstValue(payload.changedBy, payload.operatorName, "RightOnQ"), + source: "internal_review" + }; + if (payload.assignedOwner) statusPayload.internalOwner = payload.assignedOwner; + if (payload.notes) statusPayload.internalNotes = payload.notes; + statusResult = updateApplicationStatus(spreadsheet, statusPayload); + } + + return { + ok: true, + applicationId: applicationId, + reviewStatus: reviewResult.reviewStatus, + partAAccepted: Boolean(statusResult), + registrationStatus: statusResult ? statusResult.registrationStatus : applicationRecord["Registration status"], + partAStatus: statusResult ? statusResult.partAStatus : applicationRecord["Part A status"], + updatedAt: now.toISOString() + }; +} + +function upsertInternalReviewRecord(spreadsheet, payload, now) { + const sheet = getOrCreateSheet(spreadsheet, INTERNAL_REVIEWS_SHEET_NAME, INTERNAL_REVIEW_HEADERS); + const values = sheet.getDataRange().getValues(); + const headers = normaliseHeaders(values[0] || INTERNAL_REVIEW_HEADERS); + const applicationIdColumn = headers.indexOf("Application ID"); + if (applicationIdColumn === -1) throw new Error("Application ID column not found in Internal reviews sheet"); + + let rowNumber = -1; + let existing = {}; + for (let index = values.length - 1; index >= 1; index -= 1) { + if (String(values[index][applicationIdColumn]) !== String(payload.applicationId)) continue; + rowNumber = index + 1; + existing = rowToObject(values[index], headers); + break; + } + + const fieldMap = { + reviewStatus: "Review status", + assignedOwner: "Assigned owner", + legalCompanyCheck: "Legal/company check", + websiteDomainCheck: "Website/domain check", + publicLinksCheck: "Public links check", + messagePurposeExamplesCheck: "Message purpose/examples check", + consentOptOutCheck: "Consent/opt-out check", + kycTrustHubCheck: "KYC/Trust Hub check", + smsFallbackRcBundleCheck: "SMS fallback/RC bundle check", + phonePreviewReadiness: "Phone preview readiness", + nextAction: "Next action", + notes: "Notes", + sourceStatus: "Source status" + }; + + const record = {}; + INTERNAL_REVIEW_HEADERS.forEach(function(header) { + record[header] = firstValue(existing[header], ""); + }); + record["Created at"] = firstValue(existing["Created at"], now); + record["Application ID"] = payload.applicationId; + record["Last updated"] = now; + + Object.keys(fieldMap).forEach(function(payloadKey) { + if (!Object.prototype.hasOwnProperty.call(payload, payloadKey)) return; + record[fieldMap[payloadKey]] = payload[payloadKey]; + }); + + const row = headers.map(function(header) { + return safeCell(record[header]); + }); + + if (rowNumber === -1) { + sheet.appendRow(row); + } else { + sheet.getRange(rowNumber, 1, 1, headers.length).setValues([row]); + } + + return { + reviewStatus: record["Review status"] + }; +} + +function updateTrustHubKyc(spreadsheet, payload) { + const now = new Date(); + const applicationId = payload.applicationId; + if (!applicationId) throw new Error("Missing application ID"); + + const applicationRecord = findApplicationRecord(spreadsheet, { applicationId: applicationId }); + if (!applicationRecord) throw new Error("Application ID not found"); + + const result = upsertTrackingRecord( + spreadsheet, + TRUST_HUB_KYC_SHEET_NAME, + TRUST_HUB_KYC_HEADERS, + buildTrustHubKycFieldMap(), + payload, + now + ); + + if (Object.prototype.hasOwnProperty.call(payload, "trustHubStatus")) { + updateApplicationStatus(spreadsheet, { + applicationId: applicationId, + trustHubStatus: payload.trustHubStatus, + eventType: "trust_hub_kyc_updated", + changedBy: firstValue(payload.changedBy, payload.operatorName, "RightOnQ"), + source: "trust_hub_kyc", + internalNotes: firstValue(payload.kycInternalNotes, applicationRecord["Internal notes"]) + }); + } + + return { + ok: true, + applicationId: applicationId, + trustHubStatus: result.record["Trust Hub status"] || "", + secondaryComplianceProfileSid: result.record["Secondary compliance profile SID"] || "", + evaluationStatus: result.record["Evaluation status"] || "", + updatedAt: now.toISOString() + }; +} + +function updateUkRcBundle(spreadsheet, payload) { + const now = new Date(); + const applicationId = payload.applicationId; + if (!applicationId) throw new Error("Missing application ID"); + + const applicationRecord = findApplicationRecord(spreadsheet, { applicationId: applicationId }); + if (!applicationRecord) throw new Error("Application ID not found"); + + const result = upsertTrackingRecord( + spreadsheet, + UK_RC_BUNDLES_SHEET_NAME, + UK_RC_BUNDLE_HEADERS, + buildUkRcBundleFieldMap(), + payload, + now + ); + + if (Object.prototype.hasOwnProperty.call(payload, "rcBundleStatus")) { + updateApplicationStatus(spreadsheet, { + applicationId: applicationId, + eventType: "uk_rc_bundle_updated", + changedBy: firstValue(payload.changedBy, payload.operatorName, "RightOnQ"), + source: "uk_rc_bundle", + internalNotes: firstValue(payload.internalNotes, applicationRecord["Internal notes"]) + }); + } + + return { + ok: true, + applicationId: applicationId, + rcBundleStatus: result.record["RC bundle status"] || "", + rcBundleSid: result.record["RC bundle SID"] || "", + fallbackRequired: result.record["Fallback required"] || "", + updatedAt: now.toISOString() + }; +} + +function upsertTrackingRecord(spreadsheet, sheetName, headersList, fieldMap, payload, now) { + const sheet = getOrCreateSheet(spreadsheet, sheetName, headersList); + const values = sheet.getDataRange().getValues(); + const headers = normaliseHeaders(values[0] || headersList); + const applicationIdColumn = headers.indexOf("Application ID"); + if (applicationIdColumn === -1) throw new Error("Application ID column not found in " + sheetName + " sheet"); + + let rowNumber = -1; + let existing = {}; + for (let index = values.length - 1; index >= 1; index -= 1) { + if (String(values[index][applicationIdColumn]) !== String(payload.applicationId)) continue; + rowNumber = index + 1; + existing = rowToObject(values[index], headers); + break; + } + + const record = {}; + headersList.forEach(function(header) { + record[header] = firstValue(existing[header], ""); + }); + record["Created at"] = firstValue(existing["Created at"], now); + record["Application ID"] = payload.applicationId; + record["Last updated"] = now; + + Object.keys(fieldMap).forEach(function(payloadKey) { + if (!Object.prototype.hasOwnProperty.call(payload, payloadKey)) return; + record[fieldMap[payloadKey]] = payload[payloadKey]; + }); + + if (Object.prototype.hasOwnProperty.call(record, "Trust Hub status updated at") && Object.prototype.hasOwnProperty.call(payload, "trustHubStatus")) { + record["Trust Hub status updated at"] = now; + } + if (Object.prototype.hasOwnProperty.call(record, "RC bundle status updated at") && Object.prototype.hasOwnProperty.call(payload, "rcBundleStatus")) { + record["RC bundle status updated at"] = now; + } + + const row = headers.map(function(header) { + return safeCell(record[header]); + }); + + if (rowNumber === -1) { + sheet.appendRow(row); + } else { + sheet.getRange(rowNumber, 1, 1, headers.length).setValues([row]); + } + + return { + record: record + }; +} + +function buildTrustHubKycFieldMap() { + return { + clientId: "Client ID", + primaryCustomerProfileSid: "Primary customer profile SID", + secondaryComplianceProfileSid: "Secondary compliance profile SID", + trustHubPolicySid: "Trust Hub policy SID", + trustHubProfileFriendlyName: "Trust Hub profile friendly name", + trustHubStatus: "Trust Hub status", + trustHubStatusCallbackConfigured: "Trust Hub status callback configured", + trustHubRejectionReason: "Trust Hub rejection reason", + trustHubErrorCode: "Trust Hub error code", + trustHubErrorDetail: "Trust Hub error detail", + businessIdentity: "Business identity", + businessType: "Business type", + businessIndustry: "Business industry", + businessRegistrationIdentifier: "Business registration identifier", + businessRegistrationNumber: "Business registration number", + businessRegionsOfOperation: "Business regions of operation", + businessWebsiteMatchStatus: "Business website match status", + addressSid: "Address SID", + addressValidationStatus: "Address validation status", + supportingDocumentSid: "Supporting document SID", + businessInfoEndUserSid: "Business info end user SID", + authorisedRep1EndUserSid: "Authorised rep 1 end user SID", + authorisedRep2EndUserSid: "Authorised rep 2 end user SID", + authorisedRep1ValidationStatus: "Authorised rep 1 validation status", + authorisedRep2ValidationStatus: "Authorised rep 2 validation status", + authorisedRepExceptionCode: "Authorised rep exception code", + authorisedRepExceptionAction: "Authorised rep exception action", + evidenceCollectionMode: "Evidence collection mode", + evidenceStatus: "Evidence status", + evidenceProvider: "Evidence provider", + evidenceInquiryId: "Evidence inquiry ID", + evidenceRegistrationId: "Evidence registration ID", + evidenceRequestedAt: "Evidence requested at", + evidenceSubmittedAt: "Evidence submitted at", + evidenceApprovedAt: "Evidence approved at", + evidenceRejectedAt: "Evidence rejected at", + evidenceRejectionReason: "Evidence rejection reason", + primaryProfileAssignmentStatus: "Primary profile assignment status", + businessInfoAssignmentStatus: "Business info assignment status", + rep1AssignmentStatus: "Rep 1 assignment status", + rep2AssignmentStatus: "Rep 2 assignment status", + addressAssignmentStatus: "Address assignment status", + evaluationStatus: "Evaluation status", + evaluationLastRunAt: "Evaluation last run at", + evaluationErrorSummary: "Evaluation error summary", + channelEndpointAssignmentStatus: "Channel endpoint assignment status", + phoneNumberSid: "Phone number SID", + kycInternalNotes: "KYC internal notes" + }; +} + +function buildUkRcBundleFieldMap() { + return { + clientId: "Client ID", + rcBundleSid: "RC bundle SID", + rcBundleStatus: "RC bundle status", + rcBundleRejectionReason: "RC bundle rejection reason", + rcBundleErrorCode: "RC bundle error code", + rcBundleErrorDetail: "RC bundle error detail", + endBusinessLegalName: "End business legal name", + businessRegistrationNumber: "Business registration number", + numberType: "Number type", + phoneNumberSid: "Phone number SID", + phoneNumber: "Phone number", + phoneNumberAssignmentStatus: "Phone number assignment status", + addressSid: "Address SID", + supportingDocumentSid: "Supporting document SID", + complianceOwner: "Compliance owner", + fallbackRequired: "Fallback required", + internalNotes: "Internal notes" + }; +} + +function validateRegistrationStatus(status) { + if (!status) return; + if (REGISTRATION_STATUS_ORDER.indexOf(status) !== -1) return; + throw new Error("Unknown registration status: " + status); +} + +function buildStatusUpdates(payload, now) { + const updates = {}; + const fieldMap = { + registrationStatus: "Registration status", + billingStatus: "Billing status", + partAStatus: "Part A status", + partBStatus: "Part B status", + twilioStatus: "Twilio status", + trustHubStatus: "Trust Hub status", + providerStatus: "Provider status", + internalOwner: "Internal owner", + nextActionOwner: "Next action owner", + nextActionNote: "Next action note", + internalNotes: "Internal notes" + }; + + Object.keys(fieldMap).forEach(function(payloadKey) { + if (!Object.prototype.hasOwnProperty.call(payload, payloadKey)) return; + updates[fieldMap[payloadKey]] = payload[payloadKey]; + }); + + if (Object.keys(updates).length) { + updates["Updated at"] = now; + updates["Last internal action at"] = now; + } + + return updates; +} + +function queueStatusCommunication(spreadsheet, payload, applicationRecord, updates, now) { + const status = updates["Registration status"]; + const templatesByStatus = { + part_a_accepted: "part_a_accepted", + phone_preview_sent: "phone_preview_sent", + video_ready_for_review: "video_ready_for_review", + registration_submitted: "registration_submitted" + }; + const templateCode = templatesByStatus[status]; + if (!templateCode) return; + + queueCommunication(spreadsheet, templateCode, { + applicationId: payload.applicationId, + applicationRecord: applicationRecord, + triggerStatus: status, + relatedEvent: payload.eventType || "manual_status_update", + now: now + }); +} + +function queueInternalReview(spreadsheet, options) { + const now = options.now || new Date(); + const record = options.applicationRecord || {}; + const nextAction = [ + "Review Part A for legal/company fit, website/domain match, public links,", + "message wording, consent/opt-out, KYC readiness, and phone preview readiness." + ].join(" "); + + const sheet = getOrCreateSheet(spreadsheet, INTERNAL_REVIEWS_SHEET_NAME, INTERNAL_REVIEW_HEADERS); + sheet.appendRow([ + now, + safeCell(options.applicationId), + "pending_review", + "RightOnQ", + "pending", + "pending", + "pending", + "pending", + "pending", + "pending_trust_hub_review", + "pending", + "pending", + safeCell(nextAction), + safeCell(buildInternalReviewNotes(record)), + safeCell(options.triggerStatus), + now + ]); +} + +function queueTrustHubKyc(spreadsheet, options) { + const now = options.now || new Date(); + const record = options.applicationRecord || {}; + const sheet = getOrCreateSheet(spreadsheet, TRUST_HUB_KYC_SHEET_NAME, TRUST_HUB_KYC_HEADERS); + sheet.appendRow([ + now, + safeCell(options.applicationId), + safeCell(record.clientId), + "", + "", + "", + safeCell(firstValue(record.legalBusinessName, record.tradingName, record.displayName)), + "not_started", + now, + "not_configured", + "", + "", + "", + "direct_customer", + safeCell(record.companyType), + safeCell(record.businessIndustry), + "UK:CRN", + safeCell(record.companiesHouseNumber), + "", + "pending_review", + "", + "pending", + "", + "", + "", + "", + "pending", + "not_collected", + "", + "", + "not_required", + "not_required", + "", + "", + "", + "", + "", + "", + "", + "", + "pending", + "pending", + "pending", + "not_required_for_launch", + "pending", + "not_run", + "", + "", + "not_started", + "", + safeCell(buildTrustHubKycNotes(record)), + now + ]); +} + +function buildTrustHubKycNotes(record) { + const notes = [ + "Legal: " + firstValue(record.legalBusinessName, "not supplied"), + "CRN: " + firstValue(record.companiesHouseNumber, "not supplied"), + "Website: " + firstValue(record.businessWebsite, record.customerWebsite, "not supplied"), + "Primary rep: " + firstValue(record.authorizedRepName, record.primaryContactName, "not supplied"), + "ID evidence is exception-only; do not store raw ID documents in this Sheet." + ]; + return notes.join(" | "); +} + +function queueUkRcBundle(spreadsheet, options) { + const now = options.now || new Date(); + const record = options.applicationRecord || {}; + const markets = asList(record.regions); + const hasUk = markets.indexOf("United Kingdom") !== -1; + const sheet = getOrCreateSheet(spreadsheet, UK_RC_BUNDLES_SHEET_NAME, UK_RC_BUNDLE_HEADERS); + sheet.appendRow([ + now, + safeCell(options.applicationId), + safeCell(record.clientId), + "", + hasUk ? "not_started" : "not_required_unless_uk_long_code", + now, + "", + "", + "", + safeCell(record.legalBusinessName), + safeCell(record.companiesHouseNumber), + "uk_long_code", + "", + "", + "not_started", + "", + "", + "end_business", + hasUk ? "to_be_confirmed" : "not_required_unless_sms_fallback", + safeCell(buildUkRcBundleNotes(record, hasUk)), + now + ]); +} + +function buildUkRcBundleNotes(record, hasUk) { + const notes = [ + "UK launch market selected: " + (hasUk ? "yes" : "no"), + "RC Bundle is separate from Secondary Compliance Profile.", + "Assign UK long-code fallback numbers to the end-business bundle before use." + ]; + if (record.usFeeStatus) notes.push("US fee status: " + record.usFeeStatus); + return notes.join(" | "); +} + +function buildInternalReviewNotes(record) { + const notes = [ + "Legal: " + firstValue(record.legalBusinessName, "not supplied"), + "Brand: " + firstValue(record.displayName, record.tradingName, "not supplied"), + "Website: " + firstValue(record.businessWebsite, record.customerWebsite, "not supplied"), + "Use case: " + firstValue(record.primaryUseCase, "not supplied"), + "KYC: do not request or store ID documents in the static form/Sheet path." + ]; + return notes.join(" | "); +} + +function queueCommunication(spreadsheet, templateCode, options) { + const now = options.now || new Date(); + const applicationRecord = options.applicationRecord || {}; + const template = buildCommunicationTemplate(templateCode, applicationRecord); + if (!template) return; + + const sheet = getOrCreateSheet(spreadsheet, COMMUNICATIONS_SHEET_NAME, COMMUNICATION_HEADERS); + sheet.appendRow([ + now, + safeCell(options.applicationId), + safeCell(templateCode), + safeCell(template.audience), + safeCell(template.recipientEmail), + safeCell(template.recipientName), + safeCell(template.subject), + "queued_manual_send", + safeCell(options.triggerStatus), + "manual", + safeCell(template.body), + safeCell(options.relatedEvent), + now + ]); +} + +function buildCommunicationTemplate(templateCode, applicationRecord) { + const clientName = firstValue( + applicationRecord["Primary contact name"], + applicationRecord.primaryContactName, + "there" + ); + const clientEmail = firstValue( + applicationRecord["Primary contact email"], + applicationRecord.primaryContactEmail + ); + const brandName = firstValue( + applicationRecord["Client name"], + applicationRecord.displayName, + applicationRecord.tradingName, + applicationRecord.legalBusinessName, + "your RCS application" + ); + + const base = { + audience: "client", + recipientEmail: clientEmail, + recipientName: clientName + }; + + const templates = { + part_a_received: { + ...base, + subject: "RightOnQ has received your RCS Part A details", + body: "Hi " + clientName + ",\n\nThanks, RightOnQ has received your Part A registration details for " + brandName + ". We will check and process the written details first. Once Part A is accepted, we will move into Part B, starting with the phone name and logo preview.\n\nRightOnQ" + }, + part_a_accepted: { + ...base, + subject: "Your RCS Part A details are ready for the phone preview stage", + body: "Hi " + clientName + ",\n\nPart A has been checked and accepted for " + brandName + ". RightOnQ can now prepare the phone name and logo preview. We will let you know when the RBM Tester invitation and branded test message have been sent.\n\nRightOnQ" + }, + phone_preview_sent: { + ...base, + subject: "Your RCS phone preview has been sent", + body: "Hi " + clientName + ",\n\nRightOnQ has sent the RBM Tester invitation and branded test message for " + brandName + ". Please accept the invitation, check how your sender name and logo appear on your phone, then return to Part B to approve it or tell us what needs changing.\n\nRightOnQ" + }, + name_logo_approved_received: { + ...base, + subject: "RightOnQ has received your name and logo approval", + body: "Hi " + clientName + ",\n\nThanks, we have received your approval for the sender name and logo for " + brandName + ". The next stage is preparing the RCS application review video.\n\nRightOnQ" + }, + name_logo_feedback_received: { + ...base, + subject: "RightOnQ has received your name and logo feedback", + body: "Hi " + clientName + ",\n\nThanks, we have received your feedback on the phone name/logo preview for " + brandName + ". We will review it before the video stage so any issue can be fixed as early as possible.\n\nRightOnQ" + }, + video_ready_for_review: { + ...base, + subject: "Your RCS review video is ready to check", + body: "Hi " + clientName + ",\n\nThe RCS application review video for " + brandName + " is ready for you to check. Please review the video, confirm the sender details, message examples, opt-in and opt-out steps, then approve it in Part B or tell us what needs changing.\n\nRightOnQ" + }, + video_approved_received: { + ...base, + subject: "RightOnQ has received your video approval", + body: "Hi " + clientName + ",\n\nThanks, we have received your approval for the RCS review video for " + brandName + ". RightOnQ can now prepare the registration pack for submission.\n\nRightOnQ" + }, + video_changes_received: { + ...base, + subject: "RightOnQ has received your video change request", + body: "Hi " + clientName + ",\n\nThanks, we have received your requested changes for the RCS review video for " + brandName + ". We will review and amend the video before submission.\n\nRightOnQ" + }, + registration_submitted: { + ...base, + subject: "Your RCS registration has been submitted", + body: "Hi " + clientName + ",\n\nRightOnQ has submitted the RCS registration pack for " + brandName + " to the provider and carrier review process. We will keep you updated and flag anything they come back with.\n\nRightOnQ" + } + }; + + return templates[templateCode] || null; +} + +function validateApplicationTokenForSubmission(spreadsheet, applicationId, suppliedToken) { + const applicationRecord = findApplicationRecord(spreadsheet, { applicationId: applicationId }); + if (!applicationRecord) return; + + const existingToken = applicationRecord["Private application token"]; + if (!existingToken) return; + if (suppliedToken && String(suppliedToken) === String(existingToken)) return; + throw new Error("This application link could not be verified. Please ask RightOnQ for a fresh link."); +} + +function updateApplicationControlFields(spreadsheet, applicationId, updates) { + const sheet = getOrCreateSheet(spreadsheet, APPLICATIONS_SHEET_NAME, APPLICATION_HEADERS); + const values = sheet.getDataRange().getValues(); + const headers = normaliseHeaders(values[0] || APPLICATION_HEADERS); + const applicationIdColumn = headers.indexOf("Application ID"); + if (applicationIdColumn === -1) throw new Error("Application ID column not found in Applications sheet"); + + let rowNumber = -1; + for (let index = values.length - 1; index >= 1; index -= 1) { + if (String(values[index][applicationIdColumn]) !== String(applicationId)) continue; + rowNumber = index + 1; + break; + } + + if (rowNumber === -1) { + upsertApplicationRecord(spreadsheet, {}, { + applicationId: applicationId, + registrationStatus: updates["Registration status"], + partAStatus: "", + now: updates["Updated at"] || new Date(), + lastClientActionAt: updates["Last client action at"] || "" + }); + rowNumber = sheet.getLastRow(); + } + + headers.forEach(function(header, index) { + if (!Object.prototype.hasOwnProperty.call(updates, header)) return; + sheet.getRange(rowNumber, index + 1).setValue(safeCell(updates[header])); + }); +} + +function upsertApplicationRecord(spreadsheet, payload, options) { + const sheet = getOrCreateSheet(spreadsheet, APPLICATIONS_SHEET_NAME, APPLICATION_HEADERS); + const values = sheet.getDataRange().getValues(); + const headers = normaliseHeaders(values[0] || APPLICATION_HEADERS); + const applicationIdColumn = headers.indexOf("Application ID"); + if (applicationIdColumn === -1) throw new Error("Application ID column not found in Applications sheet"); + + let rowNumber = -1; + let existing = {}; + for (let index = values.length - 1; index >= 1; index -= 1) { + if (String(values[index][applicationIdColumn]) !== String(options.applicationId)) continue; + rowNumber = index + 1; + existing = rowToObject(values[index], headers); + break; + } + + const now = options.now; + const lastClientActionAt = Object.prototype.hasOwnProperty.call(options, "lastClientActionAt") ? options.lastClientActionAt : now; + const record = { + "Application ID": options.applicationId, + "Client ID": firstValue(payload.clientId, existing["Client ID"]), + "CRM company ID": firstValue(payload.crmCompanyId, existing["CRM company ID"]), + "CRM deal ID": firstValue(payload.crmDealId, existing["CRM deal ID"]), + "CRM source record URL": firstValue(payload.crmSourceRecordUrl, existing["CRM source record URL"]), + "Private application token": firstValue(payload.privateApplicationToken, existing["Private application token"]), + "Client name": firstValue(payload.displayName, payload.tradingName, payload.legalBusinessName, existing["Client name"]), + "Legal business name": firstValue(payload.legalBusinessName, existing["Legal business name"]), + "Trading name": firstValue(payload.tradingName, existing["Trading name"]), + "Primary contact name": firstValue(payload.primaryContactName, existing["Primary contact name"]), + "Primary contact email": firstValue(payload.primaryContactEmail, existing["Primary contact email"]), + "Primary contact phone": firstValue(payload.primaryContactPhone, existing["Primary contact phone"]), + "Campaign code": firstValue(payload.campaignCode, existing["Campaign code"]), + "Message code": firstValue(payload.messageCode, existing["Message code"]), + "Qualified use case": firstValue(payload.qualifiedUseCase, payload.primaryUseCase, existing["Qualified use case"]), + "Package interest": firstValue(payload.packageInterest, existing["Package interest"]), + "Handoff date": firstValue(payload.handoffDate, existing["Handoff date"]), + "Sales context": firstValue(payload.salesContext, existing["Sales context"]), + "Package name": firstValue(payload.packageName, existing["Package name"]), + "Registration status": mostAdvancedStatus(existing["Registration status"], options.registrationStatus), + "Billing status": firstValue(existing["Billing status"], payload.billingStatus), + "Part A status": firstValue(options.partAStatus, existing["Part A status"]), + "Part B status": firstValue(existing["Part B status"], payload.partBStatus), + "Twilio status": firstValue(existing["Twilio status"], payload.twilioStatus), + "Trust Hub status": firstValue(existing["Trust Hub status"], payload.trustHubStatus, "not_started"), + "Provider status": firstValue(existing["Provider status"], payload.providerStatus), + "Internal owner": firstValue(existing["Internal owner"], payload.internalOwner), + "Created at": firstValue(existing["Created at"], now), + "Updated at": now, + "Last client action at": firstValue(lastClientActionAt, existing["Last client action at"]), + "Last internal action at": firstValue(existing["Last internal action at"], ""), + "Next action owner": firstValue(existing["Next action owner"], payload.nextActionOwner), + "Next action note": firstValue(existing["Next action note"], payload.nextActionNote), + "Internal notes": firstValue(existing["Internal notes"], payload.internalNotes) + }; + + const row = headers.map(function(header) { + return safeCell(record[header]); + }); + + if (rowNumber === -1) { + sheet.appendRow(row); + } else { + sheet.getRange(rowNumber, 1, 1, headers.length).setValues([row]); + } +} + +function findApplicationRecord(spreadsheet, criteria) { + const sheet = spreadsheet.getSheetByName(APPLICATIONS_SHEET_NAME); + if (!sheet) return null; + + const values = sheet.getDataRange().getValues(); + if (values.length < 2) return null; + + const headers = normaliseHeaders(values[0]); + const applicationIdColumn = headers.indexOf("Application ID"); + const tokenColumn = headers.indexOf("Private application token"); + const applicationId = typeof criteria === "object" ? criteria.applicationId : criteria; + const privateApplicationToken = typeof criteria === "object" ? criteria.privateApplicationToken : ""; + if (applicationIdColumn === -1) return null; + + for (let rowIndex = values.length - 1; rowIndex >= 1; rowIndex -= 1) { + const row = values[rowIndex]; + const idMatches = applicationId && String(row[applicationIdColumn]) === String(applicationId); + const tokenMatches = privateApplicationToken && tokenColumn !== -1 && String(row[tokenColumn]) === String(privateApplicationToken); + if (applicationId && privateApplicationToken && (!idMatches || !tokenMatches)) continue; + if (applicationId && !privateApplicationToken && !idMatches) continue; + if (!applicationId && privateApplicationToken && !tokenMatches) continue; + if (!applicationId && !privateApplicationToken) continue; + return rowToObject(row, headers); + } + + return null; +} + +function findLatestRecordByApplicationId(spreadsheet, sheetName, applicationId) { + const records = findRecentRecordsByApplicationId(spreadsheet, sheetName, applicationId, 1); + return records.length ? records[0] : {}; +} + +function findRecentRecordsByApplicationId(spreadsheet, sheetName, applicationId, limit) { + const sheet = spreadsheet.getSheetByName(sheetName); + if (!sheet) return []; + + const values = sheet.getDataRange().getValues(); + if (values.length < 2) return []; + + const headers = normaliseHeaders(values[0]); + const applicationIdColumn = headers.indexOf("Application ID"); + if (applicationIdColumn === -1) return []; + + const records = []; + for (let index = values.length - 1; index >= 1; index -= 1) { + if (String(values[index][applicationIdColumn]) !== String(applicationId)) continue; + records.push(sanitiseOperatorRecord(rowToObject(values[index], headers))); + if (records.length >= limit) break; + } + return records; +} + +function sanitiseOperatorRecord(record) { + const output = {}; + Object.keys(record).forEach(function(key) { + if (key === "Private application token") return; + if (key === "Submission JSON") { + output[key] = "[redacted in operator snapshot]"; + return; + } + output[key] = serialiseOperatorValue(record[key]); + }); + return output; +} + +function serialiseOperatorValue(value) { + if (Object.prototype.toString.call(value) === "[object Date]" && !isNaN(value.getTime())) { + return value.toISOString(); + } + return value || ""; +} + +function getOrCreateSheet(spreadsheet, name, headers) { + let sheet = spreadsheet.getSheetByName(name); + if (!sheet) sheet = spreadsheet.insertSheet(name); + + if (sheet.getLastRow() === 0) { + sheet.appendRow(headers); + } else { + const currentHeaders = normaliseHeaders(sheet.getRange(1, 1, 1, Math.max(sheet.getLastColumn(), headers.length)).getValues()[0]); + const missingHeaders = headers.filter(function(header) { + return currentHeaders.indexOf(header) === -1; + }); + if (missingHeaders.length) { + sheet.getRange(1, currentHeaders.length + 1, 1, missingHeaders.length).setValues([missingHeaders]); + } + } + + return sheet; +} + +function rowToObject(row, headers) { + const output = {}; + headers.forEach(function(header, index) { + output[header] = row[index] || ""; + }); + return output; +} + +function normaliseHeaders(headers) { + return headers.map(function(header) { + return String(header || "").trim(); + }).filter(Boolean); +} + +function mostAdvancedStatus(existingStatus, incomingStatus) { + if (!existingStatus) return incomingStatus || "draft"; + if (!incomingStatus) return existingStatus; + + const existingIndex = REGISTRATION_STATUS_ORDER.indexOf(existingStatus); + const incomingIndex = REGISTRATION_STATUS_ORDER.indexOf(incomingStatus); + if (existingIndex === -1 || incomingIndex === -1) return incomingStatus; + return incomingIndex > existingIndex ? incomingStatus : existingStatus; +} + +function notifyAdam(payload, submissionId, countries, usSelected) { + if (!NOTIFY_EMAIL) return; + + const subject = "New RCS Part A received: " + firstValue(payload.displayName, payload.tradingName, payload.legalBusinessName, submissionId); + const body = [ + "A new RCS Part A submission has been received.", + "", + "Submission ID: " + submissionId, + "Business: " + (payload.legalBusinessName || ""), + "Trading/display name: " + firstValue(payload.displayName, payload.tradingName, ""), + "Contact: " + (payload.primaryContactName || ""), + "Email: " + (payload.primaryContactEmail || ""), + "Phone: " + (payload.primaryContactPhone || ""), + "Use case: " + (payload.primaryUseCase || ""), + "Public profile description: " + (payload.senderDescription || ""), + "Launch countries: " + countries.join(", "), + "United States selected: " + usSelected, + "", + "Open the intake sheet:", + "https://docs.google.com/spreadsheets/d/" + SPREADSHEET_ID + "/edit" + ].join("\n"); + + MailApp.sendEmail(NOTIFY_EMAIL, subject, body); +} + +function notifyNameLogoApproval(payload, decision, issueCategories, registrationStatus) { + if (!NOTIFY_EMAIL) return; + + const subjectPrefix = decision === "approve" ? "RCS name/logo approved" : "RCS name/logo needs attention"; + const body = [ + "A Part B name/logo response has been received.", + "", + "Application ID: " + (payload.applicationId || ""), + "Decision: " + decision, + "Tester invite received: " + (payload.testerReceived || ""), + "Name/logo decision: " + (payload.nameLogoDecision || ""), + "Issue categories: " + issueCategories.join(", "), + "Issue notes: " + (payload.issueNotes || ""), + "Registration status: " + registrationStatus, + "", + "Open the intake sheet:", + "https://docs.google.com/spreadsheets/d/" + SPREADSHEET_ID + "/edit" + ].join("\n"); + + MailApp.sendEmail(NOTIFY_EMAIL, subjectPrefix + ": " + (payload.applicationId || "unknown application"), body); +} + +function notifyVideoApproval(payload, decision, approvalChecklist, changeCategories, registrationStatus) { + if (!NOTIFY_EMAIL) return; + + const subjectPrefix = decision === "approve" ? "RCS video approved" : "RCS video changes requested"; + const body = [ + "A Part B video review response has been received.", + "", + "Application ID: " + (payload.applicationId || ""), + "Decision: " + decision, + "Approval checklist: " + approvalChecklist.join(", "), + "Changes requested: " + changeCategories.join(", "), + "Change notes: " + (payload.changeNotes || ""), + "Registration status: " + registrationStatus, + "", + "Open the intake sheet:", + "https://docs.google.com/spreadsheets/d/" + SPREADSHEET_ID + "/edit" + ].join("\n"); + + MailApp.sendEmail(NOTIFY_EMAIL, subjectPrefix + ": " + (payload.applicationId || "unknown application"), body); +} + +function buildSubmissionId(payload, date) { + const stamp = Utilities.formatDate(date, "Europe/London", "yyyyMMdd-HHmm"); + const name = firstValue(payload.displayName, payload.tradingName, payload.legalBusinessName, "CLIENT") + .toString() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 18) || "CLIENT"; + return "RCS-" + stamp + "-" + name; +} + +function buildApplicationId(payload, date) { + const stamp = Utilities.formatDate(date, "Europe/London", "yyyyMMddHHmmss"); + const seed = firstValue(payload.displayName, payload.tradingName, payload.legalBusinessName, "CLIENT") + .toString() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 18) || "CLIENT"; + return "ROQ-RCS-" + stamp + "-" + seed; +} + +function buildPrivateApplicationToken() { + return Utilities.getUuid().replace(/-/g, "") + Utilities.getUuid().replace(/-/g, "").slice(0, 16); +} + +function buildPrivateApplicationLink(applicationId, privateApplicationToken) { + return PUBLIC_FORM_URL + + "?applicationId=" + encodeURIComponent(applicationId) + + "&applicationToken=" + encodeURIComponent(privateApplicationToken); +} + +function asList(value) { + if (Array.isArray(value)) return value.filter(Boolean); + if (!value) return []; + return [value]; +} + +function firstValue() { + for (let i = 0; i < arguments.length; i += 1) { + if (arguments[i]) return arguments[i]; + } + return ""; +} + +function readColumn(row, headers, name) { + const index = headers.indexOf(name); + if (index === -1) return ""; + return row[index] || ""; +} + +function serialiseDate(value) { + if (!value) return ""; + if (Object.prototype.toString.call(value) === "[object Date]") return value.toISOString(); + return String(value); +} + +function finalValue(incoming, fallback) { + return incoming === undefined || incoming === null || incoming === "" ? fallback || "" : incoming; +} + +function sanitiseAuditPayload(payload) { + const copy = { ...payload }; + [ + "operatorPin", + "createPin", + "privateApplicationToken", + "applicationToken", + "private_application_token", + "token" + ].forEach(function(key) { + if (Object.prototype.hasOwnProperty.call(copy, key)) copy[key] = "[redacted]"; + }); + return copy; +} + +function safeCell(value) { + if (value === null || value === undefined) return ""; + if (Object.prototype.toString.call(value) === "[object Date]") return value; + const text = String(value); + return /^[=+\-@]/.test(text) ? "'" + text : text; +} + +function jsonResponse(data) { + return ContentService + .createTextOutput(JSON.stringify(data)) + .setMimeType(ContentService.MimeType.JSON); +} diff --git a/rcs-registration/google-apps-script/README.md b/rcs-registration/google-apps-script/README.md new file mode 100644 index 0000000..6d49171 --- /dev/null +++ b/rcs-registration/google-apps-script/README.md @@ -0,0 +1,111 @@ +# RightOnQ RCS Part A Intake - Google Apps Script + +This folder contains the Google Apps Script receiver for the static RCS registration form. + +## Intake Sheet + +Sheet: + +https://docs.google.com/spreadsheets/d/1_C85rMaDWS0-VnXbtYQzRBS1trgN8kFf4hAnHfT3R-0/edit + +Tab: + +`Part A submissions` + +## Deploy Steps + +1. Open the Google Sheet above. +2. Go to `Extensions` > `Apps Script`. +3. Paste the contents of `Code.gs` into the Apps Script editor. +4. Save the script. +5. Click `Deploy` > `New deployment`. +6. Select type `Web app`. +7. Set `Execute as` to yourself / the RightOnQ Google account. +8. Set `Who has access` to `Anyone`. +9. Deploy and authorise the requested permissions. +10. Copy the Web app URL. +11. Paste that URL into `rcs-registration/index.html`: + +```js +const partASubmissionEndpoint = "PASTE_WEB_APP_URL_HERE"; +``` + +## Current Deployment + +Apps Script project: + +https://script.google.com/d/1RUuIglGVcVpNSveeXlzw6O0wJ_A5QTtGCHwRMrJoUSSiyZ0TD_DD9ad8/edit + +Live web app URL: + +https://script.google.com/macros/s/AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6/exec + +Deployment: + +- Execute as: `adam@rightonq.co.uk` +- Access: `Anyone` +- Current published version after CLI redeploy: `22` +- Version `4` added Application ID, registration status, and Part A status columns to the intake row. +- Version `5` adds Application ID status lookup via `GET ?applicationId=...`. +- Version `6` adds the `Applications` control-row tab and writes/reads one row per Application ID. +- Version `7` adds private application token support and a guarded internal application-draft creation action. +- Versions `8` and `9` were temporary proof deployments. +- Version `11` is the clean deployment after proof; token-protected application status now requires the matching token. +- Version `12` adds B2 name/logo approval storage in the `Part B approvals` tab and updates the matching `Applications` control row. +- Version `13` adds B3 video approval/change storage in the `Part B video approvals` tab and updates the matching `Applications` control row. +- Version `14` adds a guarded internal `updateApplicationStatus` action, a `Status events` audit log, and redacts sensitive tokens/PINs from stored audit JSON. +- Version `15` adds the `Communications` manual-send queue and first customer communication templates. +- Version `16` adds the `Internal reviews` operator checklist tab and a `Trust Hub status` control field. +- Version `17` adds a guarded `updateInternalReview` action for RightOnQ checklist updates and optional Part A acceptance. +- Version `18` adds a guarded `getOperatorSnapshot` action for RightOnQ application readback. +- Version `19` updates the default internal KYC checklist state from `pending_isa_reply` to `pending_trust_hub_review` after Twilio's Isa Bell reply. +- Version `20` adds internal `Trust Hub KYC` and `UK RC bundles` tracking rows for future Part A submissions and includes them in guarded operator snapshots. +- Version `21` adds guarded operator update actions for `Trust Hub KYC` and `UK RC bundles`. +- Version `22` adds status/ID tracking fields for exception-only authorised-representative evidence collection, without adding raw identity-document storage. + +## Behaviour + +The static form posts the Part A JSON payload to the Web app URL. + +The script: + +- appends one new row per submission, +- stores the application ID and initial registration/Part A statuses, +- creates or updates the matching row in the `Applications` control tab, +- returns the latest status for a supplied Application ID or private application token, +- rejects Part A submission into a token-protected application if the supplied token does not match, +- appends B2 name/logo approval or issue responses to `Part B approvals`, +- updates the matching `Applications` row to `name_logo_approved` or `name_logo_changes_requested`, +- appends B3 video approval or change responses to `Part B video approvals`, +- updates the matching `Applications` row to `video_approved` or `video_changes_requested`, +- supports guarded internal status updates through `action = updateApplicationStatus`, +- supports guarded internal checklist updates through `action = updateInternalReview`, +- supports guarded operator readback through `action = getOperatorSnapshot`, +- supports guarded Trust Hub KYC updates through `action = updateTrustHubKyc`, +- supports guarded UK RC Bundle updates through `action = updateUkRcBundle`, +- appends successful internal status changes to `Status events`, +- redacts private application tokens and operator/create PINs from stored audit JSON, +- appends customer communication drafts to `Communications` for manual send/review, +- appends a RightOnQ operator checklist row to `Internal reviews` when Part A is received, +- appends internal Trust Hub and UK RC Bundle tracking rows when Part A is received, +- stores `Trust Hub status` on the `Applications` control row, +- sets review status to `New`, +- sets US fee status to `Not yet agreed` if United States is selected, +- stores the raw JSON payload in the `Part A JSON` column, +- sends an email notification to `adam@rightonq.co.uk`. + +If the endpoint is not configured or the POST fails, the form downloads the client copy and asks the user to email it to Adam. + +## Important + +Do not put secrets in the static HTML page. The Apps Script URL is not a password; it is a receiver endpoint. Keep the Sheet private to RightOnQ. + +The internal `createApplicationDraft` action is guarded by the script property `ONBOARDING_CREATE_PIN`. + +The internal `updateApplicationStatus` action is guarded by the script property `ONBOARDING_OPERATOR_PIN`. + +Do not store either PIN in this repo, in static HTML, or in Sheet audit JSON. If `ONBOARDING_OPERATOR_PIN` is not configured, internal status updates correctly return `ONBOARDING_OPERATOR_PIN is not configured`. + +Local application-link creation can be sent with `rcs-registration/tools/operator-create-application.mjs`, which reads `RCS_ONBOARDING_CREATE_PIN` from the local environment. Local operator updates can be sent with `rcs-registration/tools/operator-review.mjs`, `rcs-registration/tools/operator-trusthub-kyc.mjs`, and `rcs-registration/tools/operator-rc-bundle.mjs`. Local operator readback can be run with `rcs-registration/tools/operator-status.mjs`. These tools never store PINs in the repo. See `rcs-registration/tools/README.md` for dry-run and live examples. + +`Communications` is currently a manual-send queue. It records draft messages and trigger context, but it does not send customer emails automatically. diff --git a/rcs-registration/google-apps-script/appsscript.json b/rcs-registration/google-apps-script/appsscript.json new file mode 100644 index 0000000..41ca1c6 --- /dev/null +++ b/rcs-registration/google-apps-script/appsscript.json @@ -0,0 +1,11 @@ +{ + "timeZone": "Europe/London", + "dependencies": { + }, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "webapp": { + "executeAs": "USER_DEPLOYING", + "access": "ANYONE_ANONYMOUS" + } +} diff --git a/rcs-registration/index.html b/rcs-registration/index.html new file mode 100644 index 0000000..1100aac --- /dev/null +++ b/rcs-registration/index.html @@ -0,0 +1,5848 @@ + + + + + + RightOnQ RCS Registration Studio + + + + + + + + + +
+ + RightOnQ + +
RCS Registration
Part A application
+
+ +
+
+
+

RCS client application

+

RCS sender registration

+

Complete Part A so RightOnQ can check your RCS registration details before Part B, the review video stage.

+
+
+ +
+ + +
+
+
+
+

Progress saved

+

Autosave ready.

+
+
+ + + +
+
+
+

Step 1

+

Business details

+

These details help match the sender profile to the real registered business behind the brand.

+
UK KYC note: before any UK SMS fallback number can go live, RightOnQ may need extra business or identity evidence for the authorised representative. Do not upload passport, driving licence, or proof-of-address documents here. If anything sensitive is required, RightOnQ will arrange a separate secure step.
+
+
+ +

Use the exact company name shown on Companies House.

+ +
+
+ +

Use the trading or brand name behind the sender, if it differs from the legal company name. The exact RCS sender display name is confirmed in Step 2.

+ +
+
+ +

Only UK Companies House registered businesses are accepted. Use the company registration number shown on Companies House for the business being registered.

+ +
+
+ +

Choose the Companies House registered structure. Sole traders and unregistered businesses are not accepted for this registration flow.

+ +
+
+ +

RightOnQ is currently accepting UK RCS applications only.

+ +
+
+ +

Use the live public website connected to this brand or sender. The site should clearly match the legal or trading name being registered.

+ +
+
+ +

Use the first line of the Companies House registered office address.

+ +
+
+ +

Add a building, floor, unit, or second address line if needed.

+ +
+
+ +

Use the town or city shown on the registered office address.

+ +
+
+ +

Optional. Add it if it appears on your registered office address.

+ +
+
+ +

Use the UK postcode for the registered office.

+ +
+
+ +

This should be the person RightOnQ can contact if anything needs clarifying.

+ +
+
+ +

We will use this for registration questions and status updates.

+ +
+
+ +

Use an international format if possible, for example +44 7123 456789.

+ +
+
+ +

Defaults to the primary contact. Change it only if a different person should authorise the registration.

+ +
+
+ +

Defaults to the primary contact email. Use the representative’s business email if different; avoid personal/free webmail where the business has its own domain.

+ +
+
+ +

Carriers ask for the representative’s role in the organisation.

+ +
+
+ +

Choose the closest match. Carriers use this to understand the sender category.

+ +
+
+
+ +
+

Step 2

+

Brand profile

+

This is the public sender identity customers will see in their messaging app.

+
Add the sender name, logo, banner and brand colour you want people to see in their messaging app. RightOnQ will check the final profile on a real phone before anything is submitted.
+
+
+ +

This is the exact name people will see when your RCS message arrives. Use the public brand name they already recognise, for example Hometown Brewery.

+ +
+
+ +

Choose one accent colour for the sender frame. To enter a hex code, click the colour code box, then use the ⌄ format arrow until the picker shows Hex.

+
+ + +
+
+
+ +

PNG, JPG or JPEG. Must be exactly 224 x 224 px and no larger than 50 KB.

+ +
+
+
+ +

PNG, JPG or JPEG. Must be exactly 1440 x 448 px and no larger than 200 KB. Use a clean image that represents the brand.

+ +
+
+
+
+
Logo
+
+ +

Preview only. Final display can vary by device, market, and carrier review.

+
+
+
+
+
+ +
+

Step 3

+

Public contact and policy links

+

These details support the public RCS sender profile and registration checks. The website, support contact details, privacy policy and terms should all clearly belong to the brand being registered.

+
+
+ +

Use a business email address customers can contact for help or questions. It should clearly belong to the brand where possible.

+ +
+
+ +

Use an active number customers can recognise and reach.

+ +
+
+ +

Use the main live website or a page that clearly belongs to the brand being registered.

+ +
+
+ +

This should explain how customer data and messaging consent are handled.

+ +
+
+ +

Use the terms customers can review before or after opting in.

+ +
+
+ +

Defaults from Box 13. Used only for RightOnQ registration updates; not customer-facing.

+ +
+
+
+ +
+

Step 4

+

Message purpose

+

Carriers need a plain description of why messages are sent, when they are sent, and what people will actually receive.

+
+
    +
  • This is review-critical wording; vague descriptions can slow down or fail the application.
  • +
  • Describe the messages people will actually receive, not just the business or industry.
  • +
  • The first draft is based on the sender name, business industry, and message purpose.
  • +
+
+ +
+
+
+
+ +

Choose what RightOnQ should select in the RCS application. Promotional means marketing or sales messages. Transactional means customer updates, alerts, appointments, order updates, account or service information.

+ +
+
+ +

Appears under your sender name in the messaging profile. Describe what messages people will actually receive, not just the company or industry. Maximum 100 characters.

+

Example: Booking updates, arrival reminders, menu news and guest offers from Hometown Brewery. (81/100)

+ +
0 / 100
+
+
+ +

An estimate is fine. This helps set expectations during review.

+ +
+
+ +

We draft this for you using your earlier answers. It should explain the normal situations where someone would receive a message, such as an update, invitation, reminder, or follow-up.

+ +
0 / 500
+
+
+ +

We draft this as an operational messaging use case. Edit it if the business sends a narrower type of message.

+ +
0 / 500
+
+
+
+ +
+

Step 5

+

Message examples

+

These examples show reviewers what people could actually receive from the RCS sender. Keep them realistic and close to the final messaging style.

+
+
    +
  • Example messages should match the message purpose and public profile description.
  • +
  • HELP should tell people how to get support using the public contact details.
  • +
  • STOP should make the opt-out confirmation clear and final.
  • +
+
+ +
+
+
+
+ +

We draft this from the sender name, industry, and message purpose. Keep it close to the messages the business will actually send.

+ +
+
+ +

Use this to show a second realistic message, such as a follow-up, offer, reminder, or confirmation request.

+ +
+
+ +

We draft this from the brand contact details. It should tell people how to get help.

+ +
+
+ +

We draft this in the expected unsubscribe style. Keep the wording clear and final.

+ +
+
+
+ +
+

Step 6

+

Confirm How People Will Agree

+

This section confirms how people will agree to receive RCS messages, and how they can stop receiving them later.

+
This is Part A: confirm how people agree to receive messages and how they can stop them. RightOnQ will prepare any review material after these details have been checked.
+
+
+ How will people agree to share their mobile number for RCS messages? * +

Choose every place where people will agree to receive RCS messages. Pick the options that best match how the business already collects permission.

+
+ + + + + + +
+ +

Choose the option or options that best describe how people will give permission to use their mobile number for RCS messages.

+
+
+
+ +

We draft the basic steps. Keep it accurate to the page, form, account, list, or record used.

+ +
0 / 500
+
+
+ +

We draft this around STOP handling. Edit it only if the opt-out flow is different.

+ +
+
+ +

Optional. Add anything that may help RightOnQ check the registration details or prepare the next stage.

+ +
0 / 500
+
+
+
+ +
+

Step 7

+

RCS launch markets

+

RightOnQ is currently preparing RCS sender registrations for UK-registered companies. Choose the countries where your business expects to send RCS messages.

+
+
+ Destination countries for your RCS messages * +

Choose every destination country that matters at launch. The application cost note shown here is about sender registration/onboarding only, not message usage charges.

+
+

Quick choices

+
+ + + +
+ +

* United States registration currently carries third-party/carrier onboarding fees of up to $700 initially and $200 annually per RCS Sender, based on current provider guidance. Selecting United States does not take payment today. RightOnQ will confirm the current fees before any US carrier submission is made, and US registration will only proceed once those fees have been agreed and paid. Fees paid for US registration will not be refunded if approval is not granted.

+

Choose individual European countries instead

+
+ + + + + + + + + + + + + + + + +
+
+

+
+ + +
+
+ +
+

Step 8

+

Registration pack: Part A

+
+
+

Review the answers below. When you are happy, continue to sign off Part A so RightOnQ can check and process the written registration details. Once Part A is accepted, we will send a phone logo preview to your agreed number/s so you can approve how your sender name and logo appear on a real phone before the RCS application video is prepared.

+
+
+
+
+ +
+

Step 9

+

Sign off and send Part A

+
+
+ +
+
+ +
+
+ +

The person approving this information for submission.

+ +
+
+ +

Use the signatory’s official role or title.

+ +
+
+ Phone number for name and logo preview * +

Choose at least one phone number for the preview. If you are not sure whether a phone is iPhone or Android, choose either box — we can check the device type when the test invitation is sent.

+
+
+ + +
+
+ + +
+
+

+
+
+ +

The date you are signing this Part A information.

+ +
+
+
This completes Part A. RightOnQ will check and process the written registration details first, then begin Part B with the phone logo preview. You will then be able to receive and approve how your sender name and logo appear on your phone before the RCS application video is prepared.
+ +
+

Thank you. Part A has been received by RightOnQ.

+

Your copy has been saved. RightOnQ will check the written registration details first. Part B starts with a real phone preview of how the sender name and logo appear, then the review video is prepared. Select 1 Part B storyboard to see the next stage.

+
+
+ +
+ +
+ + + +

A copy will automatically download for your records.

+
+
+
+ +
+
+
+ +
+ + + + diff --git a/rcs-registration/tools/README.md b/rcs-registration/tools/README.md new file mode 100644 index 0000000..d2ea860 --- /dev/null +++ b/rcs-registration/tools/README.md @@ -0,0 +1,230 @@ +# RCS Operator Tools + +These tools are local RightOnQ operator helpers for the RCS onboarding pilot. + +They call the deployed Apps Script web app, but they do not store PINs in this repo. Always use `--dry-run` first, then run the live command only when the Apps Script-side PIN has been configured. + +## Tools + +| Tool | Purpose | Local PIN | +| --- | --- | --- | +| `operator-create-application.mjs` | Create a private application record/link from a qualified CRM or outreach handoff. | `RCS_ONBOARDING_CREATE_PIN` | +| `operator-status.mjs` | Read the guarded operator snapshot for one application. | `RCS_ONBOARDING_OPERATOR_PIN` | +| `operator-review.mjs` | Update the internal review checklist and optionally mark Part A accepted. | `RCS_ONBOARDING_OPERATOR_PIN` | +| `operator-trusthub-kyc.mjs` | Update the internal Trust Hub KYC tracking row and sync the application Trust Hub status. | `RCS_ONBOARDING_OPERATOR_PIN` | +| `operator-rc-bundle.mjs` | Update the internal UK RC Bundle tracking row. | `RCS_ONBOARDING_OPERATOR_PIN` | +| `proof-public-part-a-submit.mjs` | Create a private test link, submit Part A through the public path, then prove Trust Hub KYC and UK RC Bundle tracking rows were created. | `RCS_ONBOARDING_CREATE_PIN` and `RCS_ONBOARDING_OPERATOR_PIN` | + +## Safety Rules + +- Do not paste real PINs into chat, docs, commits, or command examples. +- Do not pass PINs as command arguments. +- Use environment variables only. +- Do not store passport, driving licence, government ID, proof-of-address, or DOB data in these tools, the static app, or the Google Sheet. +- Treat private application links as client-specific. + +## Create A Private Application Link + +Dry run: + +```bash +node rcs-registration/tools/operator-create-application.mjs \ + --legal-business-name "Example Trading Ltd" \ + --trading-name "Example Trading" \ + --primary-contact-name "Jane Smith" \ + --primary-contact-email jane@example.com \ + --primary-contact-phone "+44 7700 900123" \ + --crm-company-id CRM-COMPANY-EXAMPLE \ + --crm-deal-id CRM-DEAL-EXAMPLE \ + --campaign-code RCS1 \ + --message-code INTRO-1 \ + --qualified-use-case "Transactional customer updates" \ + --package-interest "Local Time Only" \ + --sales-context "Qualified by outreach" \ + --dry-run +``` + +Live run: + +```bash +RCS_ONBOARDING_CREATE_PIN="..." node rcs-registration/tools/operator-create-application.mjs \ + --legal-business-name "Example Trading Ltd" \ + --trading-name "Example Trading" \ + --primary-contact-name "Jane Smith" \ + --primary-contact-email jane@example.com \ + --primary-contact-phone "+44 7700 900123" \ + --crm-company-id CRM-COMPANY-EXAMPLE \ + --crm-deal-id CRM-DEAL-EXAMPLE \ + --campaign-code RCS1 \ + --message-code INTRO-1 \ + --qualified-use-case "Transactional customer updates" \ + --package-interest "Local Time Only" \ + --sales-context "Qualified by outreach" +``` + +Expected live result: JSON containing `applicationId` and `privateApplicationLink`. + +## Read Operator Snapshot + +Dry run: + +```bash +node rcs-registration/tools/operator-status.mjs \ + --application-id ROQ-RCS-... \ + --dry-run +``` + +Live run: + +```bash +RCS_ONBOARDING_OPERATOR_PIN="..." node rcs-registration/tools/operator-status.mjs \ + --application-id ROQ-RCS-... +``` + +Expected live result: JSON containing application status, latest internal review, Trust Hub KYC row, UK RC Bundle row, recent status events, and queued communications. + +## Approve Part A After Internal Review + +Dry run: + +```bash +node rcs-registration/tools/operator-review.mjs \ + --application-id ROQ-RCS-... \ + --review-status accepted \ + --part-a-accepted \ + --legal-company-check passed \ + --website-domain-check passed \ + --public-links-check passed \ + --message-purpose-examples-check passed \ + --consent-opt-out-check passed \ + --kyc-trust-hub-check pending_trust_hub_review \ + --sms-fallback-rc-bundle-check pending \ + --phone-preview-readiness ready \ + --next-action "Prepare the phone name and logo preview." \ + --operator-name "RightOnQ" \ + --dry-run +``` + +Live run: + +```bash +RCS_ONBOARDING_OPERATOR_PIN="..." node rcs-registration/tools/operator-review.mjs \ + --application-id ROQ-RCS-... \ + --review-status accepted \ + --part-a-accepted \ + --legal-company-check passed \ + --website-domain-check passed \ + --public-links-check passed \ + --message-purpose-examples-check passed \ + --consent-opt-out-check passed \ + --kyc-trust-hub-check pending_trust_hub_review \ + --sms-fallback-rc-bundle-check pending \ + --phone-preview-readiness ready \ + --next-action "Prepare the phone name and logo preview." \ + --operator-name "RightOnQ" +``` + +Expected live result: JSON showing `partAAccepted: true`, with `registrationStatus` and `partAStatus` set to `part_a_accepted`. + +## Update Trust Hub KYC Tracking + +Dry run: + +```bash +node rcs-registration/tools/operator-trusthub-kyc.mjs \ + --application-id ROQ-RCS-... \ + --trust-hub-status pending_review \ + --secondary-compliance-profile-sid BUxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ + --evaluation-status not_run \ + --evidence-collection-mode twilio_managed \ + --evidence-status requested \ + --kyc-internal-notes "Secondary profile prepared for manual Twilio review." \ + --dry-run +``` + +Live run: + +```bash +RCS_ONBOARDING_OPERATOR_PIN="..." node rcs-registration/tools/operator-trusthub-kyc.mjs \ + --application-id ROQ-RCS-... \ + --trust-hub-status pending_review \ + --secondary-compliance-profile-sid BUxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ + --evaluation-status not_run \ + --evidence-collection-mode twilio_managed \ + --evidence-status requested \ + --kyc-internal-notes "Secondary profile prepared for manual Twilio review." +``` + +Expected live result: JSON showing the latest `trustHubStatus`, any stored secondary profile SID, and evaluation status. This action also syncs `Applications.Trust Hub status`. + +Evidence fields are status/ID fields only. Do not store session tokens, passport numbers, driving licence numbers, DOB, proof-of-address files, or identity document files in this workflow. + +## Update UK RC Bundle Tracking + +Dry run: + +```bash +node rcs-registration/tools/operator-rc-bundle.mjs \ + --application-id ROQ-RCS-... \ + --rc-bundle-status pending_review \ + --fallback-required yes \ + --compliance-owner end_business \ + --internal-notes "UK long-code fallback bundle prepared." \ + --dry-run +``` + +Live run: + +```bash +RCS_ONBOARDING_OPERATOR_PIN="..." node rcs-registration/tools/operator-rc-bundle.mjs \ + --application-id ROQ-RCS-... \ + --rc-bundle-status pending_review \ + --fallback-required yes \ + --compliance-owner end_business \ + --internal-notes "UK long-code fallback bundle prepared." +``` + +Expected live result: JSON showing the latest `rcBundleStatus`, any stored RC Bundle SID, and fallback status. + +## Recommended Operator Order + +1. Create the private application link with `operator-create-application.mjs`. +2. Check the application with `operator-status.mjs`. +3. After the customer submits Part A, check status again. +4. Complete RightOnQ review using `operator-review.mjs`. +5. Check status again with `operator-status.mjs`. + +## Public Part A Submission Proof + +Dry run: + +```bash +node rcs-registration/tools/proof-public-part-a-submit.mjs --dry-run +``` + +Live proof: + +```bash +RCS_ONBOARDING_CREATE_PIN="..." RCS_ONBOARDING_OPERATOR_PIN="..." node rcs-registration/tools/proof-public-part-a-submit.mjs +``` + +Expected live result: + +- a private test application is created; +- a Part A test payload is submitted through the normal public Apps Script path using the private application token; +- the redacted operator snapshot shows: + - `registrationStatus = part_a_submitted`; + - an `Internal reviews` row; + - a `Trust Hub KYC` row; + - a `UK RC bundles` row; + - a queued `part_a_received` communication. + +The helper does not print the private application token, private link, create PIN, or operator PIN. + +## If A Tool Fails + +- `ONBOARDING_CREATE_PIN is not configured`: the Apps Script-side create PIN is missing. +- `ONBOARDING_OPERATOR_PIN is not configured`: the Apps Script-side operator PIN is missing. +- `Invalid onboarding create PIN`: the local create PIN does not match the Apps Script property. +- `Invalid onboarding operator PIN`: the local operator PIN does not match the Apps Script property. +- `Non-JSON response from Apps Script`: the web app URL may be wrong, redeployed incorrectly, or blocked by an auth/config issue. diff --git a/rcs-registration/tools/operator-create-application.mjs b/rcs-registration/tools/operator-create-application.mjs new file mode 100755 index 0000000..6e56cd5 --- /dev/null +++ b/rcs-registration/tools/operator-create-application.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +const DEFAULT_WEB_APP_URL = + "https://script.google.com/macros/s/AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6/exec"; + +const FIELD_ALIASES = { + "application-id": "applicationId", + "client-id": "clientId", + "crm-company-id": "crmCompanyId", + "crm-deal-id": "crmDealId", + "crm-source-record-url": "crmSourceRecordUrl", + "client-name": "displayName", + "legal-business-name": "legalBusinessName", + "trading-name": "tradingName", + "primary-contact-name": "primaryContactName", + "primary-contact-email": "primaryContactEmail", + "primary-contact-phone": "primaryContactPhone", + "campaign-code": "campaignCode", + "message-code": "messageCode", + "qualified-use-case": "qualifiedUseCase", + "package-interest": "packageInterest", + "handoff-date": "handoffDate", + "sales-context": "salesContext", + "package-name": "packageName", + "billing-status": "billingStatus", + "trust-hub-status": "trustHubStatus", + "internal-owner": "internalOwner", + "next-action-owner": "nextActionOwner", + "next-action-note": "nextActionNote", + "internal-notes": "internalNotes" +}; + +const BOOLEAN_FLAGS = { + "dry-run": "dryRun" +}; + +function usage() { + return [ + "Usage:", + " RCS_ONBOARDING_CREATE_PIN=... node rcs-registration/tools/operator-create-application.mjs --legal-business-name \"ABC Ltd\" --primary-contact-email client@example.com", + "", + "Common fields:", + " --application-id ROQ-RCS-... Optional; generated if omitted", + " --client-id CLIENT-001", + " --crm-company-id ...", + " --crm-deal-id ...", + " --crm-source-record-url ...", + " --legal-business-name \"ABC Ltd\"", + " --trading-name \"ABC\"", + " --client-name \"ABC\"", + " --primary-contact-name \"Jane Smith\"", + " --primary-contact-email jane@example.com", + " --primary-contact-phone +447700900123", + " --campaign-code RCS1", + " --message-code INTRO-1", + " --qualified-use-case \"Transactional customer updates\"", + " --package-interest \"Local Time Only\"", + " --sales-context \"Qualified by Roy\"", + "", + "Safety:", + " The create PIN is read from RCS_ONBOARDING_CREATE_PIN.", + " The PIN is never printed and should not be passed as a command argument.", + " Use --dry-run to print the payload without sending it.", + " A successful live run returns a private application link; treat that link as client-specific." + ].join("\n"); +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--help" || token === "-h") { + options.help = true; + continue; + } + if (!token.startsWith("--")) throw new Error("Unexpected argument: " + token); + + const rawName = token.slice(2); + if (Object.prototype.hasOwnProperty.call(BOOLEAN_FLAGS, rawName)) { + options[BOOLEAN_FLAGS[rawName]] = true; + continue; + } + + const fieldName = FIELD_ALIASES[rawName]; + if (!fieldName) throw new Error("Unknown option: " + token); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("Missing value for " + token); + options[fieldName] = value; + index += 1; + } + return options; +} + +function buildPayload(options) { + const createPin = process.env.RCS_ONBOARDING_CREATE_PIN; + if (!options.dryRun && !createPin) { + throw new Error("Set RCS_ONBOARDING_CREATE_PIN before creating a live application link"); + } + + const payload = { + action: "createApplicationDraft" + }; + + Object.keys(FIELD_ALIASES).forEach(function(rawName) { + const fieldName = FIELD_ALIASES[rawName]; + if (options[fieldName] !== undefined) payload[fieldName] = options[fieldName]; + }); + + if (!options.dryRun) payload.createPin = createPin; + return payload; +} + +function sanitisePayload(payload) { + const copy = { ...payload }; + if (copy.createPin) copy.createPin = "[redacted]"; + return copy; +} + +async function postJson(url, payload) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + redirect: "follow" + }); + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch (error) { + throw new Error("Non-JSON response from Apps Script: " + text.slice(0, 500)); + } + if (!response.ok || data.ok === false) { + throw new Error(data.error || "Apps Script request failed with HTTP " + response.status); + } + return data; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const payload = buildPayload(options); + if (options.dryRun) { + console.log(JSON.stringify(sanitisePayload(payload), null, 2)); + return; + } + + const webAppUrl = process.env.RCS_ONBOARDING_WEB_APP_URL || DEFAULT_WEB_APP_URL; + const result = await postJson(webAppUrl, payload); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch(function(error) { + console.error(error.message); + process.exit(1); +}); diff --git a/rcs-registration/tools/operator-rc-bundle.mjs b/rcs-registration/tools/operator-rc-bundle.mjs new file mode 100644 index 0000000..249dddf --- /dev/null +++ b/rcs-registration/tools/operator-rc-bundle.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +const DEFAULT_WEB_APP_URL = + "https://script.google.com/macros/s/AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6/exec"; + +const FIELD_ALIASES = { + "application-id": "applicationId", + "client-id": "clientId", + "rc-bundle-sid": "rcBundleSid", + "rc-bundle-status": "rcBundleStatus", + "rc-bundle-rejection-reason": "rcBundleRejectionReason", + "rc-bundle-error-code": "rcBundleErrorCode", + "rc-bundle-error-detail": "rcBundleErrorDetail", + "end-business-legal-name": "endBusinessLegalName", + "business-registration-number": "businessRegistrationNumber", + "number-type": "numberType", + "phone-number-sid": "phoneNumberSid", + "phone-number": "phoneNumber", + "phone-number-assignment-status": "phoneNumberAssignmentStatus", + "address-sid": "addressSid", + "supporting-document-sid": "supportingDocumentSid", + "compliance-owner": "complianceOwner", + "fallback-required": "fallbackRequired", + "internal-notes": "internalNotes", + "operator-name": "operatorName", + "changed-by": "changedBy" +}; + +const BOOLEAN_FLAGS = { + "dry-run": "dryRun" +}; + +function usage() { + return [ + "Usage:", + " RCS_ONBOARDING_OPERATOR_PIN=... node rcs-registration/tools/operator-rc-bundle.mjs --application-id ROQ-RCS-... --rc-bundle-status pending_review", + "", + "Common fields:", + " --application-id Required application ID", + " --rc-bundle-status pending_review", + " --rc-bundle-sid BU...", + " --phone-number-sid PN...", + " --phone-number +441234567890", + " --phone-number-assignment-status assigned", + " --fallback-required yes", + " --internal-notes \"Operator note\"", + "", + "Safety:", + " The operator PIN is read from RCS_ONBOARDING_OPERATOR_PIN.", + " Store Twilio IDs, status values, and rejection reasons only; do not store raw identity evidence.", + " Use --dry-run to print the payload without sending it." + ].join("\n"); +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--help" || token === "-h") { + options.help = true; + continue; + } + if (!token.startsWith("--")) throw new Error("Unexpected argument: " + token); + + const rawName = token.slice(2); + if (Object.prototype.hasOwnProperty.call(BOOLEAN_FLAGS, rawName)) { + options[BOOLEAN_FLAGS[rawName]] = true; + continue; + } + + const fieldName = FIELD_ALIASES[rawName]; + if (!fieldName) throw new Error("Unknown option: " + token); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("Missing value for " + token); + options[fieldName] = value; + index += 1; + } + return options; +} + +function buildPayload(options) { + if (!options.applicationId) throw new Error("Missing --application-id"); + + const operatorPin = process.env.RCS_ONBOARDING_OPERATOR_PIN; + if (!options.dryRun && !operatorPin) { + throw new Error("Set RCS_ONBOARDING_OPERATOR_PIN before running a live RC Bundle update"); + } + + const payload = { + action: "updateUkRcBundle", + applicationId: options.applicationId + }; + + Object.keys(FIELD_ALIASES).forEach(function(rawName) { + const fieldName = FIELD_ALIASES[rawName]; + if (fieldName === "applicationId") return; + if (options[fieldName] !== undefined) payload[fieldName] = options[fieldName]; + }); + + if (!options.dryRun) payload.operatorPin = operatorPin; + return payload; +} + +function sanitisePayload(payload) { + const copy = { ...payload }; + if (copy.operatorPin) copy.operatorPin = "[redacted]"; + return copy; +} + +async function postJson(url, payload) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + redirect: "follow" + }); + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch (error) { + throw new Error("Non-JSON response from Apps Script: " + text.slice(0, 500)); + } + if (!response.ok || data.ok === false) { + throw new Error(data.error || "Apps Script request failed with HTTP " + response.status); + } + return data; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const payload = buildPayload(options); + if (options.dryRun) { + console.log(JSON.stringify(sanitisePayload(payload), null, 2)); + return; + } + + const webAppUrl = process.env.RCS_ONBOARDING_WEB_APP_URL || DEFAULT_WEB_APP_URL; + const result = await postJson(webAppUrl, payload); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch(function(error) { + console.error(error.message); + process.exit(1); +}); diff --git a/rcs-registration/tools/operator-review.mjs b/rcs-registration/tools/operator-review.mjs new file mode 100755 index 0000000..bbcba44 --- /dev/null +++ b/rcs-registration/tools/operator-review.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +const DEFAULT_WEB_APP_URL = + "https://script.google.com/macros/s/AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6/exec"; + +const FIELD_ALIASES = { + "application-id": "applicationId", + "review-status": "reviewStatus", + "assigned-owner": "assignedOwner", + "legal-company-check": "legalCompanyCheck", + "website-domain-check": "websiteDomainCheck", + "public-links-check": "publicLinksCheck", + "message-purpose-examples-check": "messagePurposeExamplesCheck", + "consent-opt-out-check": "consentOptOutCheck", + "kyc-trust-hub-check": "kycTrustHubCheck", + "sms-fallback-rc-bundle-check": "smsFallbackRcBundleCheck", + "phone-preview-readiness": "phonePreviewReadiness", + "next-action": "nextAction", + "source-status": "sourceStatus", + "operator-name": "operatorName", + "changed-by": "changedBy", + notes: "notes" +}; + +const BOOLEAN_FLAGS = { + "part-a-accepted": "partAAccepted", + "dry-run": "dryRun" +}; + +function usage() { + return [ + "Usage:", + " RCS_ONBOARDING_OPERATOR_PIN=... node rcs-registration/tools/operator-review.mjs --application-id ROQ-RCS-... --review-status accepted --part-a-accepted", + "", + "Common fields:", + " --application-id Required application ID", + " --review-status pending_review, accepted, changes_needed, etc.", + " --part-a-accepted Also moves Part A to part_a_accepted", + " --legal-company-check passed Updates checklist fields", + " --website-domain-check passed", + " --public-links-check passed", + " --message-purpose-examples-check passed", + " --consent-opt-out-check passed", + " --kyc-trust-hub-check pending_isa_reply", + " --sms-fallback-rc-bundle-check pending", + " --phone-preview-readiness ready", + " --next-action \"Prepare the phone name and logo preview.\"", + " --notes \"Operator note\"", + " --operator-name \"RightOnQ\"", + "", + "Safety:", + " The operator PIN is read from RCS_ONBOARDING_OPERATOR_PIN.", + " The PIN is never printed and should not be passed as a command argument.", + " Use --dry-run to print the payload without sending it." + ].join("\n"); +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--help" || token === "-h") { + options.help = true; + continue; + } + if (!token.startsWith("--")) { + throw new Error("Unexpected argument: " + token); + } + + const rawName = token.slice(2); + if (Object.prototype.hasOwnProperty.call(BOOLEAN_FLAGS, rawName)) { + options[BOOLEAN_FLAGS[rawName]] = true; + continue; + } + + const fieldName = FIELD_ALIASES[rawName]; + if (!fieldName) throw new Error("Unknown option: " + token); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("Missing value for " + token); + options[fieldName] = value; + index += 1; + } + return options; +} + +function buildPayload(options) { + if (!options.applicationId) throw new Error("Missing --application-id"); + + const operatorPin = process.env.RCS_ONBOARDING_OPERATOR_PIN; + if (!options.dryRun && !operatorPin) { + throw new Error("Set RCS_ONBOARDING_OPERATOR_PIN before running a live operator update"); + } + + const payload = { + action: "updateInternalReview", + applicationId: options.applicationId + }; + + Object.keys(FIELD_ALIASES).forEach(function(rawName) { + const fieldName = FIELD_ALIASES[rawName]; + if (options[fieldName] !== undefined) payload[fieldName] = options[fieldName]; + }); + + if (options.partAAccepted) payload.partAAccepted = true; + if (!options.dryRun) payload.operatorPin = operatorPin; + + return payload; +} + +function sanitisePayload(payload) { + const copy = { ...payload }; + if (copy.operatorPin) copy.operatorPin = "[redacted]"; + return copy; +} + +async function postJson(url, payload) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + redirect: "follow" + }); + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch (error) { + throw new Error("Non-JSON response from Apps Script: " + text.slice(0, 500)); + } + if (!response.ok || data.ok === false) { + throw new Error(data.error || "Apps Script request failed with HTTP " + response.status); + } + return data; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const payload = buildPayload(options); + if (options.dryRun) { + console.log(JSON.stringify(sanitisePayload(payload), null, 2)); + return; + } + + const webAppUrl = process.env.RCS_ONBOARDING_WEB_APP_URL || DEFAULT_WEB_APP_URL; + const result = await postJson(webAppUrl, payload); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch(function(error) { + console.error(error.message); + process.exit(1); +}); diff --git a/rcs-registration/tools/operator-status.mjs b/rcs-registration/tools/operator-status.mjs new file mode 100755 index 0000000..87f0784 --- /dev/null +++ b/rcs-registration/tools/operator-status.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +const DEFAULT_WEB_APP_URL = + "https://script.google.com/macros/s/AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6/exec"; + +function usage() { + return [ + "Usage:", + " RCS_ONBOARDING_OPERATOR_PIN=... node rcs-registration/tools/operator-status.mjs --application-id ROQ-RCS-...", + "", + "Options:", + " --application-id ROQ-RCS-... Required application ID", + " --dry-run Print the guarded request payload without sending it", + "", + "Safety:", + " The operator PIN is read from RCS_ONBOARDING_OPERATOR_PIN.", + " The PIN is never printed and should not be passed as a command argument." + ].join("\n"); +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--help" || token === "-h") { + options.help = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--application-id") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("Missing value for --application-id"); + options.applicationId = value; + index += 1; + continue; + } + throw new Error("Unknown option: " + token); + } + return options; +} + +function buildPayload(options) { + if (!options.applicationId) throw new Error("Missing --application-id"); + + const operatorPin = process.env.RCS_ONBOARDING_OPERATOR_PIN; + if (!options.dryRun && !operatorPin) { + throw new Error("Set RCS_ONBOARDING_OPERATOR_PIN before running a live operator status check"); + } + + const payload = { + action: "getOperatorSnapshot", + applicationId: options.applicationId + }; + if (!options.dryRun) payload.operatorPin = operatorPin; + return payload; +} + +function sanitisePayload(payload) { + const copy = { ...payload }; + if (copy.operatorPin) copy.operatorPin = "[redacted]"; + return copy; +} + +async function postJson(url, payload) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + redirect: "follow" + }); + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch (error) { + throw new Error("Non-JSON response from Apps Script: " + text.slice(0, 500)); + } + if (!response.ok || data.ok === false) { + throw new Error(data.error || "Apps Script request failed with HTTP " + response.status); + } + return data; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const payload = buildPayload(options); + if (options.dryRun) { + console.log(JSON.stringify(sanitisePayload(payload), null, 2)); + return; + } + + const webAppUrl = process.env.RCS_ONBOARDING_WEB_APP_URL || DEFAULT_WEB_APP_URL; + const result = await postJson(webAppUrl, payload); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch(function(error) { + console.error(error.message); + process.exit(1); +}); diff --git a/rcs-registration/tools/operator-trusthub-kyc.mjs b/rcs-registration/tools/operator-trusthub-kyc.mjs new file mode 100644 index 0000000..6bc3d3f --- /dev/null +++ b/rcs-registration/tools/operator-trusthub-kyc.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +const DEFAULT_WEB_APP_URL = + "https://script.google.com/macros/s/AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6/exec"; + +const FIELD_ALIASES = { + "application-id": "applicationId", + "client-id": "clientId", + "primary-customer-profile-sid": "primaryCustomerProfileSid", + "secondary-compliance-profile-sid": "secondaryComplianceProfileSid", + "trust-hub-policy-sid": "trustHubPolicySid", + "trust-hub-profile-friendly-name": "trustHubProfileFriendlyName", + "trust-hub-status": "trustHubStatus", + "trust-hub-status-callback-configured": "trustHubStatusCallbackConfigured", + "trust-hub-rejection-reason": "trustHubRejectionReason", + "trust-hub-error-code": "trustHubErrorCode", + "trust-hub-error-detail": "trustHubErrorDetail", + "business-identity": "businessIdentity", + "business-type": "businessType", + "business-industry": "businessIndustry", + "business-registration-identifier": "businessRegistrationIdentifier", + "business-registration-number": "businessRegistrationNumber", + "business-regions-of-operation": "businessRegionsOfOperation", + "business-website-match-status": "businessWebsiteMatchStatus", + "address-sid": "addressSid", + "address-validation-status": "addressValidationStatus", + "supporting-document-sid": "supportingDocumentSid", + "business-info-end-user-sid": "businessInfoEndUserSid", + "authorised-rep-1-end-user-sid": "authorisedRep1EndUserSid", + "authorised-rep-2-end-user-sid": "authorisedRep2EndUserSid", + "authorised-rep-1-validation-status": "authorisedRep1ValidationStatus", + "authorised-rep-2-validation-status": "authorisedRep2ValidationStatus", + "authorised-rep-exception-code": "authorisedRepExceptionCode", + "authorised-rep-exception-action": "authorisedRepExceptionAction", + "evidence-collection-mode": "evidenceCollectionMode", + "evidence-status": "evidenceStatus", + "evidence-provider": "evidenceProvider", + "evidence-inquiry-id": "evidenceInquiryId", + "evidence-registration-id": "evidenceRegistrationId", + "evidence-requested-at": "evidenceRequestedAt", + "evidence-submitted-at": "evidenceSubmittedAt", + "evidence-approved-at": "evidenceApprovedAt", + "evidence-rejected-at": "evidenceRejectedAt", + "evidence-rejection-reason": "evidenceRejectionReason", + "primary-profile-assignment-status": "primaryProfileAssignmentStatus", + "business-info-assignment-status": "businessInfoAssignmentStatus", + "rep-1-assignment-status": "rep1AssignmentStatus", + "rep-2-assignment-status": "rep2AssignmentStatus", + "address-assignment-status": "addressAssignmentStatus", + "evaluation-status": "evaluationStatus", + "evaluation-last-run-at": "evaluationLastRunAt", + "evaluation-error-summary": "evaluationErrorSummary", + "channel-endpoint-assignment-status": "channelEndpointAssignmentStatus", + "phone-number-sid": "phoneNumberSid", + "kyc-internal-notes": "kycInternalNotes", + "operator-name": "operatorName", + "changed-by": "changedBy" +}; + +const BOOLEAN_FLAGS = { + "dry-run": "dryRun" +}; + +function usage() { + return [ + "Usage:", + " RCS_ONBOARDING_OPERATOR_PIN=... node rcs-registration/tools/operator-trusthub-kyc.mjs --application-id ROQ-RCS-... --trust-hub-status pending_review", + "", + "Common fields:", + " --application-id Required application ID", + " --trust-hub-status pending_review Updates Trust Hub KYC row and Applications.Trust Hub status", + " --secondary-compliance-profile-sid BU...", + " --trust-hub-policy-sid RN...", + " --business-website-match-status passed", + " --evaluation-status passed", + " --authorised-rep-exception-code 18019", + " --authorised-rep-exception-action request_twilio_managed_id_check", + " --evidence-collection-mode twilio_managed", + " --evidence-status requested", + " --evidence-inquiry-id inq_xxxxxxxxxxxxxxxxxxxxxxxx", + " --kyc-internal-notes \"Operator note\"", + "", + "Safety:", + " The operator PIN is read from RCS_ONBOARDING_OPERATOR_PIN.", + " Do not use this tool to store passport, driving licence, DOB, proof-of-address, or raw identity documents.", + " Use --dry-run to print the payload without sending it." + ].join("\n"); +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--help" || token === "-h") { + options.help = true; + continue; + } + if (!token.startsWith("--")) throw new Error("Unexpected argument: " + token); + + const rawName = token.slice(2); + if (Object.prototype.hasOwnProperty.call(BOOLEAN_FLAGS, rawName)) { + options[BOOLEAN_FLAGS[rawName]] = true; + continue; + } + + const fieldName = FIELD_ALIASES[rawName]; + if (!fieldName) throw new Error("Unknown option: " + token); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("Missing value for " + token); + options[fieldName] = value; + index += 1; + } + return options; +} + +function buildPayload(options) { + if (!options.applicationId) throw new Error("Missing --application-id"); + + const operatorPin = process.env.RCS_ONBOARDING_OPERATOR_PIN; + if (!options.dryRun && !operatorPin) { + throw new Error("Set RCS_ONBOARDING_OPERATOR_PIN before running a live Trust Hub update"); + } + + const payload = { + action: "updateTrustHubKyc", + applicationId: options.applicationId + }; + + Object.keys(FIELD_ALIASES).forEach(function(rawName) { + const fieldName = FIELD_ALIASES[rawName]; + if (fieldName === "applicationId") return; + if (options[fieldName] !== undefined) payload[fieldName] = options[fieldName]; + }); + + if (!options.dryRun) payload.operatorPin = operatorPin; + return payload; +} + +function sanitisePayload(payload) { + const copy = { ...payload }; + if (copy.operatorPin) copy.operatorPin = "[redacted]"; + return copy; +} + +async function postJson(url, payload) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + redirect: "follow" + }); + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch (error) { + throw new Error("Non-JSON response from Apps Script: " + text.slice(0, 500)); + } + if (!response.ok || data.ok === false) { + throw new Error(data.error || "Apps Script request failed with HTTP " + response.status); + } + return data; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const payload = buildPayload(options); + if (options.dryRun) { + console.log(JSON.stringify(sanitisePayload(payload), null, 2)); + return; + } + + const webAppUrl = process.env.RCS_ONBOARDING_WEB_APP_URL || DEFAULT_WEB_APP_URL; + const result = await postJson(webAppUrl, payload); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch(function(error) { + console.error(error.message); + process.exit(1); +}); diff --git a/rcs-registration/tools/proof-public-part-a-submit.mjs b/rcs-registration/tools/proof-public-part-a-submit.mjs new file mode 100644 index 0000000..1fde54f --- /dev/null +++ b/rcs-registration/tools/proof-public-part-a-submit.mjs @@ -0,0 +1,311 @@ +#!/usr/bin/env node + +const DEFAULT_WEB_APP_URL = + "https://script.google.com/macros/s/AKfycbyI81Ir2xvHLar0R0iFBBWyXa1Nj93T4_8Ni5_eX3XEYDA-AKQbVYbPHnTROLm8e4a6/exec"; + +const BOOLEAN_FLAGS = { + "dry-run": "dryRun" +}; + +const FIELD_ALIASES = { + "application-id": "applicationId", + "legal-business-name": "legalBusinessName", + "trading-name": "tradingName", + "primary-contact-email": "primaryContactEmail", + "primary-contact-name": "primaryContactName", + "primary-contact-phone": "primaryContactPhone" +}; + +function usage() { + return [ + "Usage:", + " RCS_ONBOARDING_CREATE_PIN=... RCS_ONBOARDING_OPERATOR_PIN=... node rcs-registration/tools/proof-public-part-a-submit.mjs", + "", + "Purpose:", + " Creates a private test application, submits Part A through the public Apps Script submission path,", + " then reads a redacted operator snapshot to prove Trust Hub KYC and UK RC Bundle rows were created.", + "", + "Safety:", + " PINs are read from environment variables only.", + " The private application token/link is not printed.", + " Use --dry-run to print redacted payloads without sending anything." + ].join("\n"); +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--help" || token === "-h") { + options.help = true; + continue; + } + if (!token.startsWith("--")) throw new Error("Unexpected argument: " + token); + + const rawName = token.slice(2); + if (Object.prototype.hasOwnProperty.call(BOOLEAN_FLAGS, rawName)) { + options[BOOLEAN_FLAGS[rawName]] = true; + continue; + } + + const fieldName = FIELD_ALIASES[rawName]; + if (!fieldName) throw new Error("Unknown option: " + token); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("Missing value for " + token); + options[fieldName] = value; + index += 1; + } + return options; +} + +function timestamp() { + return new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); +} + +function buildApplicationId(options) { + return options.applicationId || `ROQ-RCS-TEST-PUBLIC-PARTA-${timestamp()}`; +} + +function buildCreatePayload(options, applicationId) { + const createPin = process.env.RCS_ONBOARDING_CREATE_PIN; + if (!options.dryRun && !createPin) { + throw new Error("Set RCS_ONBOARDING_CREATE_PIN before running the live proof"); + } + + const legalName = options.legalBusinessName || "TEST Public Part A Proof Ltd"; + const tradingName = options.tradingName || "TEST Public Part A Proof"; + const contactName = options.primaryContactName || "Test Public Submitter"; + const contactEmail = options.primaryContactEmail || "test-public-parta@example.com"; + const contactPhone = options.primaryContactPhone || "+44 7000 000003"; + + const payload = { + action: "createApplicationDraft", + applicationId, + legalBusinessName: legalName, + tradingName, + displayName: tradingName, + primaryContactName: contactName, + primaryContactEmail: contactEmail, + primaryContactPhone: contactPhone, + crmCompanyId: "CRM-COMPANY-PUBLIC-PARTA-TEST", + crmDealId: "CRM-DEAL-PUBLIC-PARTA-TEST", + campaignCode: "RCS1", + messageCode: "PUBLIC-PARTA-PROOF", + qualifiedUseCase: "Transactional customer updates", + packageInterest: "Local Time Only", + salesContext: "Public Part A submission proof" + }; + + if (!options.dryRun) payload.createPin = createPin; + return payload; +} + +function buildPartAPayload(createPayload, applicationId, privateApplicationToken) { + return { + applicationId, + privateApplicationToken, + registrationStatus: "part_a_submitted", + partAStatus: "part_a_submitted", + submissionId: `RCS-${timestamp().slice(0, 8)}-PUBLIC-PARTA-PROOF`, + submittedAt: new Date().toISOString(), + legalBusinessName: createPayload.legalBusinessName, + tradingName: createPayload.tradingName, + companiesHouseNumber: "12345678", + companyType: "Private limited company", + registrationCountry: "United Kingdom", + registeredAddressLine1: "1 Test Street", + registeredAddressLine2: "", + registeredCity: "London", + registeredCounty: "", + registeredPostcode: "EC1A 1AA", + registeredAddress: "1 Test Street\nLondon\nEC1A 1AA\nUnited Kingdom", + businessWebsite: "https://example.com", + primaryContactName: createPayload.primaryContactName, + primaryContactEmail: createPayload.primaryContactEmail, + primaryContactPhone: createPayload.primaryContactPhone, + authorizedRepName: createPayload.primaryContactName, + authorizedRepEmail: createPayload.primaryContactEmail, + authorizedRepTitle: "Director", + businessIndustry: "Retail", + displayName: createPayload.displayName, + brandColour: "#3f8cff", + customerEmail: createPayload.primaryContactEmail, + customerPhone: createPayload.primaryContactPhone, + customerWebsite: "https://example.com", + privacyPolicyUrl: "https://example.com/privacy", + termsUrl: "https://example.com/terms", + notificationEmail: createPayload.primaryContactEmail, + primaryUseCase: "Transactional", + senderDescription: "Order updates and service messages from TEST Public Part A Proof.", + monthlyVolume: "Under 10,000", + messageTrigger: "Customers receive messages after placing an order or requesting a service update.", + useCaseDescription: "Transactional customer updates about orders, appointments, support and service progress.", + exampleMessageOne: "Hi Alex, your order from TEST Public Part A Proof has been received. Reply HELP for support or STOP to opt out.", + exampleMessageTwo: "Your appointment is confirmed for Friday at 10:00. Reply HELP for support or STOP to opt out.", + helpSampleMessage: "Thanks for contacting TEST Public Part A Proof. For help, email support@example.com or call +44 7000 000003.", + stopSampleMessage: "You have opted out of TEST Public Part A Proof messages. You will not receive further RCS updates.", + consentRoute: ["website_form"], + consentRoutes: ["website_form"], + optInDescription: "Customers tick an optional consent box on the website before receiving RCS updates.", + optOutDescription: "Customers can reply STOP at any time. STOP is shown in message examples and honoured before further sends.", + reviewerAccess: "Test proof submission only.", + regions: ["United Kingdom"], + organicTraffic: "", + existingSmsTraffic: "", + accuracyDeclaration: "Confirmed", + agencySubmissionDeclaration: "Confirmed", + signatoryName: createPayload.primaryContactName, + signatoryTitle: "Director", + iphonePreviewNumber: "+44 7000 000003", + androidPreviewNumber: "", + signoffDate: new Date().toISOString().slice(0, 10), + logoUpload: { + name: "test-logo.png", + width: 224, + height: 224, + valid: true + }, + bannerUpload: { + name: "test-banner.png", + width: 1440, + height: 448, + valid: true + }, + templateVersion: "2026-05-06" + }; +} + +function buildSnapshotPayload(applicationId) { + const operatorPin = process.env.RCS_ONBOARDING_OPERATOR_PIN; + if (!operatorPin) throw new Error("Set RCS_ONBOARDING_OPERATOR_PIN before reading the live snapshot"); + return { + action: "getOperatorSnapshot", + applicationId, + operatorPin + }; +} + +function sanitisePayload(payload) { + const copy = JSON.parse(JSON.stringify(payload)); + if (copy.createPin) copy.createPin = "[redacted]"; + if (copy.operatorPin) copy.operatorPin = "[redacted]"; + if (copy.privateApplicationToken) copy.privateApplicationToken = "[redacted]"; + return copy; +} + +function extractToken(privateApplicationLink) { + const url = new URL(privateApplicationLink); + const token = url.searchParams.get("applicationToken") || + url.searchParams.get("privateApplicationToken") || + url.searchParams.get("token"); + if (!token) throw new Error("Private application token was not present in created link"); + return token; +} + +async function postJson(url, payload) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "text/plain;charset=utf-8" }, + body: JSON.stringify(payload), + redirect: "follow" + }); + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch (error) { + throw new Error("Non-JSON response from Apps Script: " + text.slice(0, 500)); + } + if (!response.ok || data.ok === false) { + throw new Error(data.error || "Apps Script request failed with HTTP " + response.status); + } + return data; +} + +function summariseSnapshot(snapshot) { + return { + ok: snapshot.ok, + applicationId: snapshot.applicationId, + application: { + registrationStatus: snapshot.application && snapshot.application.registrationStatus, + partAStatus: snapshot.application && snapshot.application.partAStatus, + trustHubStatus: snapshot.application && snapshot.application.trustHubStatus, + nextActionOwner: snapshot.application && snapshot.application.nextActionOwner, + nextActionNote: snapshot.application && snapshot.application.nextActionNote + }, + internalReview: { + reviewStatus: snapshot.internalReview && snapshot.internalReview["Review status"], + kycTrustHubCheck: snapshot.internalReview && snapshot.internalReview["KYC/Trust Hub check"], + smsFallbackRcBundleCheck: snapshot.internalReview && snapshot.internalReview["SMS fallback/RC bundle check"] + }, + trustHubKyc: { + present: Boolean(snapshot.trustHubKyc && snapshot.trustHubKyc["Application ID"]), + status: snapshot.trustHubKyc && snapshot.trustHubKyc["Trust Hub status"], + profileName: snapshot.trustHubKyc && snapshot.trustHubKyc["Trust Hub profile friendly name"], + authorisedRepExceptionAction: snapshot.trustHubKyc && snapshot.trustHubKyc["Authorised rep exception action"] + }, + ukRcBundle: { + present: Boolean(snapshot.ukRcBundle && snapshot.ukRcBundle["Application ID"]), + status: snapshot.ukRcBundle && snapshot.ukRcBundle["RC bundle status"], + fallbackRequired: snapshot.ukRcBundle && snapshot.ukRcBundle["Fallback required"], + complianceOwner: snapshot.ukRcBundle && snapshot.ukRcBundle["Compliance owner"] + }, + recentStatusEventTypes: (snapshot.recentStatusEvents || []).map(event => event["Event type"]).filter(Boolean), + queuedCommunicationCodes: (snapshot.queuedCommunications || []).map(row => row["Communication code"]).filter(Boolean) + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const applicationId = buildApplicationId(options); + const createPayload = buildCreatePayload(options, applicationId); + const dryRunPartAPayload = buildPartAPayload(createPayload, applicationId, "[private token from created link]"); + + if (options.dryRun) { + console.log(JSON.stringify({ + createPayload: sanitisePayload(createPayload), + partAPayload: sanitisePayload(dryRunPartAPayload), + snapshotPayload: { + action: "getOperatorSnapshot", + applicationId, + operatorPin: "[redacted]" + } + }, null, 2)); + return; + } + + const webAppUrl = process.env.RCS_ONBOARDING_WEB_APP_URL || DEFAULT_WEB_APP_URL; + const created = await postJson(webAppUrl, createPayload); + const privateApplicationToken = extractToken(created.privateApplicationLink); + const partAPayload = buildPartAPayload(createPayload, applicationId, privateApplicationToken); + const submitted = await postJson(webAppUrl, partAPayload); + const snapshot = await postJson(webAppUrl, buildSnapshotPayload(applicationId)); + + console.log(JSON.stringify({ + created: { + ok: created.ok, + applicationId: created.applicationId, + registrationStatus: created.registrationStatus, + partAStatus: created.partAStatus, + privateApplicationLinkPresent: Boolean(created.privateApplicationLink) + }, + submitted: { + ok: submitted.ok, + applicationId: submitted.applicationId, + submissionId: submitted.submissionId, + registrationStatus: submitted.registrationStatus, + receivedAt: submitted.receivedAt + }, + snapshot: summariseSnapshot(snapshot) + }, null, 2)); +} + +main().catch(function(error) { + console.error(error.message); + process.exit(1); +});