1 module arsd.ecommerce; 2 3 import std..string; 4 import std.conv; 5 6 static import std.date; 7 static import std.regex; 8 9 import arsd.curl; 10 import arsd.dom; 11 12 /* 13 unit = "months" or "days" 14 */ 15 16 struct CreditCard { 17 string number; 18 int expirationMonth; 19 int expirationYear; 20 21 /// Filters out invalid characters 22 @property string cardNumber() const { 23 return std.regex.replace( 24 cast(string) // the cast is necessary currently to work around a const bug in the stdlib 25 number, std.regex.regex("[^0-9]", "g"), ""); 26 } 27 28 bool validateChecksum() { 29 string num = this.cardNumber; 30 31 auto check = num[$-1] - '0'; 32 33 bool isTwo = true; 34 35 int sum = check; 36 for(int a = num.length - 2; a >= 0; a--) { 37 int val = (num[a] - '0') * (isTwo ? 2 : 1); 38 if(val >= 10) 39 val -= 9; 40 41 sum += val; 42 isTwo = !isTwo; 43 } 44 45 return (sum % 10 == 0); 46 } 47 48 /// Returns the expiration date as a string authorize.net expects 49 @property string expirationDate() const { 50 int m = expirationMonth; 51 int y = expirationYear; 52 53 if(y < 1000) 54 y += 2000; 55 56 return format("%d-%02d", y, m); 57 } 58 } 59 60 struct CustomerInfo { 61 string firstName; 62 string lastName; 63 string emailAddress; 64 65 string company; 66 67 string phoneNumber; 68 69 string address; 70 string address2; 71 string city; 72 string state; 73 string zip; 74 } 75 76 struct SubscriptionInfo { 77 string name; /// name of the subscription, can be anything 78 int durationLength; /// duration of the payment cycle. 30 means billed every 30 units - see durationUnit 79 Unit durationUnit; /// the units of the duration length (payment cycle length). Can currently be days or months. 80 Money amount; /// the amount to bill each cycle 81 82 Money trialAmount; 83 int trialOccurrences; 84 85 enum Unit { 86 days, months 87 } 88 89 @property string unitString() const { 90 // the compiler can tell us the string of the enum... 91 string[] unitNames; 92 foreach(name; __traits(allMembers, SubscriptionInfo.Unit)) 93 unitNames ~= name; 94 95 return unitNames[cast(int) durationUnit]; 96 } 97 } 98 99 100 struct Date { 101 long timestamp; // a d_time value; unix time in milliseconds 102 103 static Date now() { 104 return Date(std.date.getUTCtime()); 105 } 106 107 string toSlashDateString() const { 108 auto day = std.date.dateFromTime(timestamp); 109 auto year = std.date.yearFromTime(timestamp); 110 auto month = std.date.monthFromTime(timestamp) + 1; 111 112 return format("%02d/%02d/%04d", month, day, year); 113 } 114 115 string toDateString() const { 116 auto day = std.date.dateFromTime(timestamp); 117 auto year = std.date.yearFromTime(timestamp); 118 auto month = std.date.monthFromTime(timestamp) + 1; 119 120 return format("%d-%02d-%02d", year, month, day); 121 } 122 } 123 124 class PaymentGatewayException : Exception { 125 public this(string message, string file = __FILE__, size_t line = __LINE__) { 126 super(message, file, line); 127 } 128 129 abstract GatewayFailure reason(); 130 abstract string reasonText(); 131 } 132 133 class VirtualMerchantGatewayException : PaymentGatewayException { 134 public this(int code, string message, string name, string[string] allVariables, string file = __FILE__, size_t line = __LINE__) { 135 this.code = code; 136 this.message = message; 137 this.name = name; 138 this.allVariables = allVariables; 139 super(message, file, line); 140 } 141 142 int code; 143 string message; 144 string name; 145 string[string] allVariables; 146 147 override GatewayFailure reason() { 148 switch(code) { 149 // see page 202 of the document 150 // BTW: 5033 is invalid recurring id 151 case 5000: return GatewayFailure.invalidCreditCardNumber; 152 case 5001: return GatewayFailure.creditCardExpired; // appears invalid 153 case 6009: return GatewayFailure.creditCardExpired; // declined due to the expired card 154 default: 155 if(code > 6000 && code < 7000) 156 return GatewayFailure.transactionDeclined; 157 return GatewayFailure.unknown; 158 } 159 } 160 161 override string reasonText() { 162 return format("%s (code %s)", message, code); 163 } 164 } 165 166 /// Thrown upon receiving a bad response from authorize.net (should never happen) or if the API returns an error. 167 class AuthorizeGatewayException : PaymentGatewayException { 168 public this(string operation, string code, string message, string file = __FILE__, size_t line = __LINE__) { 169 this.code = code; 170 this.operation = operation; 171 this.message = message; 172 173 super(operation ~ " failed: " ~ message, file, line); 174 } 175 176 override GatewayFailure reason() { 177 switch(code) { 178 case "E00013": return GatewayFailure.creditCardExpired; // it has already expired 179 case "E00018": return GatewayFailure.creditCardExpired; // it will expire before the subscription starts 180 default: return GatewayFailure.unknown; 181 } 182 } 183 184 override string reasonText() { 185 return message; 186 } 187 188 string code; 189 string operation; 190 string message; 191 } 192 193 enum GatewayFailure { 194 unknown, 195 invalidCreditCardNumber, 196 creditCardExpired, 197 transactionDeclined, 198 } 199 200 struct VirtualMerchantCredentials { 201 string merchantId; 202 string userId; 203 string pin; 204 } 205 206 void deleteVirtualMerchantSubscription(in VirtualMerchantCredentials credentials, in string subscriptionId) { 207 string[string] args; 208 args["ssl_transaction_type"] = "ccdeleterecurring"; 209 args["ssl_recurring_id"] = subscriptionId; 210 assert(0, to!string(doVirtualMerchantRequest(credentials, args))); 211 } 212 213 void suspendVirtualMerchantSubscription(in VirtualMerchantCredentials credentials, in string subscriptionId) { 214 string[string] args; 215 args["ssl_transaction_type"] = "ccupdaterecurring"; 216 args["ssl_recurring_id"] = subscriptionId; 217 args["ssl_billing_cycle"] = "SUSPENDED"; 218 assert(0, to!string(doVirtualMerchantRequest(credentials, args))); 219 } 220 221 void restoreVirtualMerchantSubscription(in VirtualMerchantCredentials credentials, in string subscriptionId, bool wasAnnual) { 222 string[string] args; 223 args["ssl_transaction_type"] = "ccupdaterecurring"; 224 args["ssl_recurring_id"] = subscriptionId; 225 if(wasAnnual) 226 args["ssl_billing_cycle"] = "ANNUALLY"; 227 else 228 args["ssl_billing_cycle"] = "MONTHLY"; 229 assert(0, to!string(doVirtualMerchantRequest(credentials, args))); 230 } 231 232 void updateVirtualMerchantSubscription(in VirtualMerchantCredentials credentials, in string subscriptionId, in CustomerInfo customer, in CreditCard card) { 233 string[string] args; 234 args["ssl_transaction_type"] = "ccupdaterecurring"; 235 236 assert(subscriptionId.length); 237 args["ssl_recurring_id"] = subscriptionId; 238 239 args["ssl_show_form"] = "false"; 240 241 // card info 242 args["ssl_card_number"] = card.cardNumber; 243 args["ssl_exp_date"] = format("%02d%02d", card.expirationMonth, card.expirationYear); 244 245 // name 246 if(customer.firstName.length) 247 args["ssl_first_name"] = customer.firstName; 248 249 if(customer.lastName.length) 250 args["ssl_last_name"] = customer.lastName; 251 252 // billing address 253 if(customer.address.length) 254 args["ssl_avs_address"] = customer.address.length > 30 ? customer.address[0 .. 30] : customer.address; 255 256 if(customer.city.length) 257 args["ssl_city"] = customer.city; 258 259 if(customer.state.length) 260 args["ssl_state"] = customer.state; 261 262 if(customer.zip.length) 263 args["ssl_avs_zip"] = customer.zip; 264 265 assert(0, to!string(doVirtualMerchantRequest(credentials, args))); 266 } 267 268 string createVirtualMerchantSubscription(in VirtualMerchantCredentials credentials, in SubscriptionInfo subscription, in Date startDate, in CustomerInfo customer, in CreditCard card) { 269 270 string[string] args; 271 args["ssl_transaction_type"] = "ccaddrecurring"; 272 args["ssl_show_form"] = "false"; 273 args["ssl_card_number"] = card.cardNumber; 274 args["ssl_exp_date"] = format("%02d%02d", card.expirationMonth, card.expirationYear); 275 args["ssl_amount"] = format("%s.%02d", subscription.amount.dollars, subscription.amount.cents); 276 args["ssl_next_payment_date"] = startDate.toSlashDateString(); 277 278 final switch(subscription.durationUnit) { 279 case SubscriptionInfo.Unit.days: 280 args["ssl_billing_cycle"] = "MONTHLY"; // FIXME: use the right values from subscription info arg 281 assert(subscription.durationLength == 30); 282 break; 283 case SubscriptionInfo.Unit.months: 284 args["ssl_billing_cycle"] = "ANNUALLY"; // FIXME check the days too 285 assert(subscription.durationLength == 12); 286 break; 287 } 288 289 args["ssl_result_format"] = "ascii"; 290 291 args["ssl_first_name"] = customer.firstName; 292 args["ssl_last_name"] = customer.lastName; 293 294 args["ssl_avs_address"] = customer.address.length > 30 ? customer.address[0 .. 30] : customer.address; 295 args["ssl_city"] = customer.city; 296 args["ssl_state"] = customer.state; 297 args["ssl_avs_zip"] = customer.zip; 298 if(customer.phoneNumber.length) 299 args["ssl_phone"] = customer.phoneNumber; 300 args["ssl_email"] = customer.emailAddress; 301 302 args["ssl_customer_code"] = ""; 303 args["ssl_salestax"] = "0.00"; 304 305 306 auto returned = doVirtualMerchantRequest(credentials, args); 307 308 if("ssl_recurring_id" in returned) 309 return returned["ssl_recurring_id"]; 310 else { 311 throw new VirtualMerchantGatewayException( 312 to!int(returned["errorCode"]), 313 returned["errorMessage"], 314 returned["errorName"], 315 returned 316 ); 317 } 318 319 assert(0); 320 } 321 322 string[string] doVirtualMerchantRequest(in VirtualMerchantCredentials credentials, string[string] args) { 323 324 args["ssl_merchant_id"] = credentials.merchantId; 325 args["ssl_user_id"] = credentials.userId; 326 args["ssl_pin"] = credentials.pin; 327 328 // see page 110 329 enum testUrl = "https://demo.myvirtualmerchant.com/VirtualMerchantDemo/process.do"; 330 enum liveUrl = "https://www.myvirtualmerchant.com/VirtualMerchant/process.do"; 331 332 version(live) 333 auto url = liveUrl; 334 else 335 auto url = testUrl; 336 337 import arsd.cgi; 338 auto infoTxt = curl(url, encodeVariables(args)); 339 340 string[string] returned; 341 foreach(line; infoTxt.split("\n")) { 342 if(line.length == 0) 343 continue; 344 string key, value; 345 auto idx = line.indexOf("="); 346 if(idx == -1) 347 key = line.strip; 348 else { 349 key = line[0 .. idx].strip; 350 value = line[idx + 1 .. $].strip; 351 } 352 353 returned[key] = value; 354 } 355 356 return returned; 357 } 358 359 /** 360 Creates a subscription with Authorize.net, returning the new subscription ID. 361 362 params: 363 subscription = Info about the subscription itself - price, name, and interval 364 startDate = when the subscription goes into effect. The first credit card charge will come on this date. 365 customer = basic info about the customer 366 card = the credit card information to bill 367 referenceId = an optional field to be stored with the subscription inside authorize.net, can be anything 368 369 returns: 370 The new subscription id from authorize.net 371 372 throws: 373 AuthorizeGatewayException if something goes wrong 374 */ 375 string createSubscription(in SubscriptionInfo subscription, in Date startDate, in CustomerInfo customer, in CreditCard card, in string referenceId = null) { 376 /* Authorize.net parameters */ 377 378 version(live) { 379 // FIXME: this is dummy test info 380 enum string loginname="8vLAwF38qF"; 381 enum string transactionkey="6sYgn3u3Nx98r5Tb"; 382 enum string host = "apitest.authorize.net"; 383 enum string path = "/xml/v1/request.api"; 384 385 // enum string host = "api.authorize.net"; 386 // enum string path = "/xml/v1/request.api"; 387 } else { 388 // use the test gateway on dev 389 enum string loginname="8vLAwF38qF"; 390 enum string transactionkey="6sYgn3u3Nx98r5Tb"; 391 enum string host = "apitest.authorize.net"; 392 enum string path = "/xml/v1/request.api"; 393 } 394 395 // we want it to continue forever 396 immutable totalOccurrences = 9999; 397 398 // we don't use the trial feature 399 immutable trialAmount = subscription.trialAmount.toString()[1..$]; 400 immutable trialOccurrences = subscription.trialOccurrences; 401 402 403 /* ********* */ 404 /* Real code */ 405 /* ********* */ 406 407 string shortenedName = subscription.name; 408 if(shortenedName.length > 50) 409 shortenedName = shortenedName[0..50]; // to ensure we have good data for auth.net 410 411 //build xml to post 412 string content = 413 "<?xml version=\"1.0\" encoding=\"utf-8\"?>" ~ 414 "<ARBCreateSubscriptionRequest xmlns=\"AnetApi/xml/v1/schema/AnetApiSchema.xsd\">" ~ 415 "<merchantAuthentication>" ~ 416 "<name>" ~ loginname ~ "</name>" ~ 417 "<transactionKey>" ~ transactionkey ~ "</transactionKey>" ~ 418 "</merchantAuthentication>" ~ 419 "<refId>" ~ xmlEntitiesEncode(referenceId) ~ "</refId>" ~ 420 "<subscription>" ~ 421 "<name>" ~ xmlEntitiesEncode(shortenedName) ~ "</name>" ~ 422 "<paymentSchedule>" ~ 423 "<interval>" ~ 424 "<length>" ~ to!string(subscription.durationLength) ~ "</length>" ~ 425 "<unit>" ~ subscription.unitString ~ "</unit>" ~ 426 "</interval>" ~ 427 "<startDate>" ~ startDate.toDateString() ~ "</startDate>" ~ 428 "<totalOccurrences>" ~ to!string(totalOccurrences) ~ "</totalOccurrences>" ~ 429 "<trialOccurrences>" ~ to!string(trialOccurrences) ~ "</trialOccurrences>" ~ 430 "</paymentSchedule>" ~ 431 "<amount>" ~ subscription.amount.toString()[1..$] ~ "</amount>" ~ // toString returns $n.nn, we don't want a $ 432 "<trialAmount>" ~ to!string(trialAmount) ~ "</trialAmount>" ~ 433 "<payment>" ~ 434 "<creditCard>" ~ 435 "<cardNumber>" ~ card.cardNumber ~ "</cardNumber>" ~ 436 "<expirationDate>" ~ card.expirationDate ~ "</expirationDate>" ~ 437 "</creditCard>" ~ 438 "</payment>" ~ 439 "<customer>" ~ 440 "<email>" ~ xmlEntitiesEncode(customer.emailAddress) ~ "</email>" ~ 441 "<phoneNumber>" ~ xmlEntitiesEncode(customer.phoneNumber) ~ "</phoneNumber>" ~ 442 "</customer>" ~ 443 "<billTo>" ~ 444 "<firstName>" ~ xmlEntitiesEncode(customer.firstName) ~ "</firstName>" ~ 445 "<lastName>" ~ xmlEntitiesEncode(customer.lastName) ~ "</lastName>" ~ 446 "<company>" ~ xmlEntitiesEncode(customer.company) ~ "</company>" ~ 447 "<address>" ~ xmlEntitiesEncode(customer.address ~ "\n" ~ customer.address2) ~ "</address>" ~ 448 "<city>" ~ xmlEntitiesEncode(customer.city) ~ "</city>" ~ 449 "<state>" ~ xmlEntitiesEncode(customer.state) ~ "</state>" ~ 450 "<zip>" ~ xmlEntitiesEncode(customer.zip) ~ "</zip>" ~ 451 "</billTo>" ~ 452 "</subscription>" ~ 453 "</ARBCreateSubscriptionRequest>"; 454 455 456 string response = curl("https://" ~ host ~ path, content, "text/xml"); 457 458 int xmlroot = response.indexOf("><"); 459 if(xmlroot == -1) 460 throw new AuthorizeGatewayException("Create subscription", null, "Bad response"); 461 462 string xml = response[xmlroot+1..$]; 463 464 Document doc = new Document(xml); 465 466 auto result = doc.getElementsByTagName("resultCode"); 467 if(result.length == 0) 468 throw new AuthorizeGatewayException("Create subscription", null, "Bad response, no result code"); 469 470 auto e = result[0].innerText; 471 472 if(e != "Ok") { 473 // buy failed, get the message out: 474 475 auto eles = doc.getElementsByTagName("text"); 476 if(eles.length == 0) // don't know the message... 477 throw new AuthorizeGatewayException("Create subscription", null, "Reason unknown"); 478 else 479 throw new AuthorizeGatewayException("Create subscription", doc.requireSelector("code").innerText, eles[0].innerText); 480 } 481 482 auto s = doc.getElementsByTagName("subscriptionId"); 483 if(s.length == 0) 484 throw new Exception("Bad response"); 485 auto subId = s[0].innerText; 486 487 return subId; 488 } 489 490 string oneTimeBill(string login, string key, CustomerInfo customer, CreditCard card, Money amount, string description, string[string] otherArgs = null) { 491 version(live) { 492 string host = "https://test.authorize.net/gateway/transact.dll"; 493 // FIXME 494 // string host = "https://secure.authorize.net/gateway/transact.dll"; 495 } else { 496 string host = "https://test.authorize.net/gateway/transact.dll"; 497 } 498 499 /* 500 x_login 501 x_tran_key 502 x_version = 3.0 503 x_recurring_billing=1 504 505 x_invoice_num 506 x_description 507 508 x_amount 509 x_card_num 510 x_exp_date = mm/yy 511 x_card_code (optional) 512 513 x_first_name 514 x_last_name 515 x_email 516 517 518 519 https://developer.authorize.net/guides/AIM/ 520 */ 521 522 void setOptional(string k, string v) { 523 if(k !in otherArgs) 524 otherArgs[k] = v; 525 } 526 527 setOptional("x_version", "3.0"); 528 setOptional("x_recurring_billing", "1"); 529 530 otherArgs["x_login"] = login; 531 otherArgs["x_tran_key"] = key; 532 otherArgs["x_description"] = description; 533 534 otherArgs["x_amount"] = format("%d.%02d", amount.dollars, amount.cents); 535 otherArgs["x_card_num"] = card.number; 536 otherArgs["x_exp_date"] = format("%02d/%02d", card.expirationMonth, card.expirationYear); 537 538 otherArgs["x_first_name"] = customer.firstName; 539 otherArgs["x_lastname"] = customer.lastName; 540 otherArgs["x_email"] = customer.emailAddress; 541 542 import arsd.cgi; 543 544 curl(host, encodeVariables(otherArgs)); // FIXME: actually check this 545 546 return null; 547 } 548 549 struct Money { 550 int amount; 551 552 this(int numberOfCents) { 553 this.amount = numberOfCents; 554 } 555 556 this(string val) { 557 assert(val.length, "can't pass an empty string as money"); 558 if(val[0] == '$') 559 val = val[1..$]; 560 val = std.array.replace(val, "%", ""); // HACK 561 562 int idx = val.indexOf("."); 563 if(idx == -1) 564 amount = to!int(val) * 100; 565 else { 566 int d = idx ? to!int(val[0..idx]) : 0; 567 int c = (idx + 1 < val.length) 568 ? to!int(val[idx + 1 .. $]) 569 : 0; 570 571 amount = d * 100 + c; 572 } 573 } 574 575 static Money fromWebString(string v) { 576 if(v.length == 0) 577 throw new Exception("Money value must not be empty"); 578 return Money(v); 579 } 580 581 @property int dollars() const { 582 return amount / 100; 583 } 584 585 @property int cents() const { 586 return amount % 100; 587 } 588 589 string toString() const { 590 return format("$%d.%02d", dollars, cents); 591 } 592 593 // a bug in std.conv means this needs to be defined too... 594 string toString() { 595 return format("$%d.%02d", dollars, cents); 596 } 597 598 Money opOpAssign(string op)(Money rhs) { 599 mixin("this.amount" ~ op ~"=" ~ "rhs.amount;"); 600 return this; 601 } 602 } 603