Wires: support sending them through Wise
Wires are awesome - and lovely to send through Column. But they're also expensive to send through Column - oof.
Wise is a platform that the operations team has been using to send lower value wires at cheaper rates. So far they've been finding it useful, but they'd like for us to be able to automate it for them.
Currently we have Wire#send_wire!:
https://github.com/hackclub/hcb/blob/c4b1be85446ee90011530e947a0a7cc61c61c9fb/app/models/wire.rb#L524
It uses Column. We should rename that to Wire#send_with_column! and then create a Wire#send_with_wise! method.
The method will need to create a recipient: https://docs.wise.com/api-docs/api-reference/recipient#create, we should have all the required information - otherwise we can start collecting it.
And then we'll need to send this newly created recipient money: https://docs.wise.com/api-docs/api-reference/transfer#create.
Based on the amount it will cost, we'll need to pay for it, I believe we can use: https://docs.wise.com/api-docs/api-reference/payin-deposit-detail#get. This should generate a Canonical Transaction that we can map to the organisation, we can use short codes or Column account numbers (send an ACH from the event's Column account numbers).
Do let me know if you'd like any help with the UX for this/getting playground API keys
See https://github.com/hackclub/hcb/issues/9854#issue-2923451628 - we should keep track of the fee we're charged.
Did a little bit more work researching this. https://docs.wise.com/api-docs/guides/send-money is a good read for anyone interested. I think because of how specific their API is we should build out our own WiseTransfer model - we can figure out a nice frontend for this but behind the scenes, I think the two should be separate models.
For Wise transfers, we'll need to build out pages for each of these stages:
In my opinion we can skip the existing recipient selection. We can also skip the quote acceptance because we'll cover the fee. When the user selects currency, destination country and amount we can determine the best approach (WiseTransfer or Wire)
Probably the most important part of the integration will be funding the transfer: https://docs.wise.com/api-docs/guides/send-money/funding. We're probably going to want to take the "Direct Bank Transfer (Push)" approach.
There are two approaches I think we could take:
-
Send two bank transfers (domestic wire or ACH), one from
636to cover the fee and one from the originating organisation to cover the transfer. -
Make two book transfers to FS Operating (split the same way as above), make one transfer from FS Operating. This would require making some sort of clearinghouse like Sweeps.
1 is probably better but both are do-able.
We'll probably need to store this on every instance of WiseTransfer and validate the equivalent of recipient_info against it:
[
{
"type": "iban",
"title": "Inside Europe",
"usageInfo": null,
"fields": [
{
"name": "Recipient type",
"group": [
{
"key": "legalType",
"name": "Recipient type",
"type": "select",
"refreshRequirementsOnChange": true,
"required": true,
"displayFormat": null,
"example": "",
"minLength": null,
"maxLength": null,
"validationRegexp": null,
"validationAsync": null,
"valuesAllowed": [
{
"key": "PRIVATE",
"name": "Person"
},
{
"key": "BUSINESS",
"name": "Business"
}
]
}
]
},
{
"name": "Email (Optional)",
"group": [
{
"key": "email",
"name": "Email (Optional)",
"type": "text",
"refreshRequirementsOnChange": false,
"required": false,
"displayFormat": null,
"example": "[email protected]",
"minLength": null,
"maxLength": 255,
"validationRegexp": "\\s*[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+\\s*",
"validationAsync": null,
"valuesAllowed": null
}
]
},
{
"name": "Full name of the account holder",
"group": [
{
"key": "accountHolderName",
"name": "Full name of the account holder",
"type": "text",
"refreshRequirementsOnChange": false,
"required": true,
"displayFormat": null,
"example": "",
"minLength": 2,
"maxLength": 140,
"validationRegexp": "^[0-9A-Za-zÀ-ÖØ-öø-ÿ-_()'*,.%#^@&/{}~<>+$\"\\[\\]\\\\ ]+$",
"validationAsync": null,
"valuesAllowed": null
}
]
},
{
"name": "Bank code (BIC/SWIFT)",
"group": [
{
"key": "BIC",
"name": "Bank code (BIC/SWIFT)",
"type": "text",
"refreshRequirementsOnChange": false,
"required": false,
"displayFormat": null,
"example": "BARCGB22XXX",
"minLength": 8,
"maxLength": 11,
"validationRegexp": "^[A-Za-z]{6}[A-Za-z\\d]{2}([A-Za-z\\d]{3})?$",
"validationAsync": null,
"valuesAllowed": null
}
]
},
{
"name": "IBAN",
"group": [
{
"key": "IBAN",
"name": "IBAN",
"type": "text",
"refreshRequirementsOnChange": true,
"required": true,
"displayFormat": "**** **** **** **** **** **** **** ****",
"example": "DE12345678901234567890",
"minLength": 14,
"maxLength": 42,
"validationRegexp": "^[a-zA-Z]{2}[a-zA-Z0-9 ]{12,40}$",
"validationAsync": null,
"valuesAllowed": null
}
]
}
]
},
{
"type": "swift_code",
"title": "Outside Europe",
"usageInfo": null,
"fields": [
{
"name": "Recipient type",
"group": [
{
"key": "legalType",
"name": "Recipient type",
"type": "select",
"refreshRequirementsOnChange": true,
"required": true,
"displayFormat": null,
"example": "",
"minLength": null,
"maxLength": null,
"validationRegexp": null,
"validationAsync": null,
"valuesAllowed": [
{
"key": "PRIVATE",
"name": "Person"
},
{
"key": "BUSINESS",
"name": "Business"
}
]
}
]
},
{
"name": "Email (Optional)",
"group": [
{
"key": "email",
"name": "Email (Optional)",
"type": "text",
"refreshRequirementsOnChange": false,
"required": false,
"displayFormat": null,
"example": "[email protected]",
"minLength": null,
"maxLength": 255,
"validationRegexp": "\\s*[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+\\s*",
"validationAsync": null,
"valuesAllowed": null
}
]
},
{
"name": "Full name of the account holder",
"group": [
{
"key": "accountHolderName",
"name": "Full name of the account holder",
"type": "text",
"refreshRequirementsOnChange": false,
"required": true,
"displayFormat": null,
"example": "",
"minLength": 2,
"maxLength": 140,
"validationRegexp": "^[0-9A-Za-zÀ-ÖØ-öø-ÿ-_()'*,.%#^@&/{}~<>+$\"\\[\\]\\\\ ]+$",
"validationAsync": null,
"valuesAllowed": null
}
]
},
{
"name": "SWIFT / BIC code",
"group": [
{
"key": "swiftCode",
"name": "SWIFT / BIC code",
"type": "text",
"refreshRequirementsOnChange": true,
"required": true,
"displayFormat": null,
"example": "BUKBGB22",
"minLength": 8,
"maxLength": 11,
"validationRegexp": "^[a-zA-Z]{6}(([a-zA-Z0-9]{2})|([a-zA-Z0-9]{5}))$",
"validationAsync": null,
"valuesAllowed": null
}
]
},
{
"name": "IBAN / Account number",
"group": [
{
"key": "accountNumber",
"name": "IBAN / Account number",
"type": "text",
"refreshRequirementsOnChange": false,
"required": true,
"displayFormat": null,
"example": "",
"minLength": 4,
"maxLength": 34,
"validationRegexp": "^[a-zA-Z0-9\\s]{4,34}$",
"validationAsync": null,
"valuesAllowed": null
}
]
}
]
}
]
And the same for the /v1/transfer-requirements endpoint
@sampoder I think the quote view is still probably a good thing to have since it includes an FX rate - i'm not sure when we would use this but it may be a good idea if we were optionally able to charge an org the fee for the transfer