Your solution seems okay for now, however, I would rather suggest you make some sort of rules policy so that your reservation does not really care how it is paid, but rather that the rules are determined by the use case (you will notice that this solution is actually also technically based on the strategy pattern).
For example, let's say you have a Theatre class which is what you are booking tickets for. The following method is on this Theatre class:
public PaymentResult MakeReservation(IPaymentPolicy paymentPolicy, int itemsToBuy)
{
var result = paymentPolicy.Verify(itemsToBuy);
if(result.HasFailingRules)
{
return result;
}
// Do your booking here.
}
Here the theatre object is responsible for a single decision - is the reservation allowed given the rules provided to me? If yes then make the booking otherwise report the errors.
Then the caller has control over the rules based on the use case. For example:
public void MakePaypalReservation(int itemsToBuy)
{
var rulesPolicy = new PaymentRulesPolicy(
new MaxItemsRule(10),
new MaxAmountRule(10000)
);
var theatre = this.repo.Load("Theatre A"); // Load by id
var paymentResult = theatre.MakeReservation(rulesPolicy, itemsToBuy);
// Here you can use the result for logging or return to the GUI or proceed with the next step if no errors are present.
}
public void MakeCashReservation(int itemsToBuy)
{
var rulesPolicy = new PaymentRulesPolicy(
new MaxItemsRule(2),
new MaxAmountRule(100),
new TimeOfDayRule(8, 20) //Can only buy between 8 in the morning at 8 at night as an example.
);
var theatre = this.repo.Load("Theatre A"); // Load by id
var paymentResult = theatre.MakeReservation(rulesPolicy, itemsToBuy);
// Here you can use the result for logging or return to the GUI or proceed with the next step if no errors are present.
}
Let's suppose that PaymentRulesPolicy has a constructor with this signature:
public PaymentRulesPolicy(params IRule[] rules);
You have a method per use case. If you can pay with some other method like a voucher, you can build a new policy with some new rules.
You will of course also have to provide the Theatre object with all the information it needs to make a booking. The rule policy's Verify() method will likely accept all of these pieces of information and pass the minimum required information to the individual rules.
Here is an example of what the rules policy might look like:
public class PaymentRulesPolicy
{
private readonly IRule[] rules;
public PaymentRulesPolicy(params IRule[] rules)
{
this.rules = rules;
}
public PaymentResult Verify(int numItemsToBuy, DateTime bookingDate)
{
var result = new PaymentResult();
foreach(var rule in this.rules)
{
result.Append(rule.Verify(numItemsToBuy, bookingDate);
}
return result;
}
}
This is already a bad interface since all rules require all information no matter what check it does. If this gets out of hand you could improve it by passing along the booking information when the policy is first constructed:
var rulesPolicy = new PaymentRulesPolicy(
new MaxItemsRule(2, itemsToBuy),
new MaxAmountRule(100, itemsToBuy, costPerItem),
new TimeOfDayRule(8, 20, currentDateTime)
);
At the end of the day the benefit of this pattern is that all your business decisions are encapsulated in one class each, which makes it extremely easy to maintain. Just looking at these policies' construction hopefully gave you a good overview of what they will enforce. You may then compose these rules into a larger policy.
The other benefit of this approach would also be unit testing. You could test rules very easily in isolation. You could even create a factory for the rules and test that the factory creates the correct policy with the correct rules for each use case, etc.
Remember that this is just one of many possible solutions and this particular solution might be overkill for your application or perhaps it does not fit well with the patterns you and your team are familiar with. As you experiment with your inheritance solution you might find that it is adequate or even simpler to understand given the habits and experience of your team.