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 
Suggestion Box / Bug Report