reji 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.editorconfig +14 -0
- data/.gitattributes +4 -0
- data/.gitignore +15 -0
- data/.travis.yml +28 -0
- data/Appraisals +17 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +133 -0
- data/LICENSE +20 -0
- data/README.md +1285 -0
- data/Rakefile +21 -0
- data/app/controllers/reji/payment_controller.rb +31 -0
- data/app/controllers/reji/webhook_controller.rb +170 -0
- data/app/views/payment.html.erb +228 -0
- data/app/views/receipt.html.erb +250 -0
- data/bin/setup +12 -0
- data/config/routes.rb +6 -0
- data/gemfiles/rails_5.0.gemfile +13 -0
- data/gemfiles/rails_5.1.gemfile +13 -0
- data/gemfiles/rails_5.2.gemfile +13 -0
- data/gemfiles/rails_6.0.gemfile +13 -0
- data/lib/generators/reji/install/install_generator.rb +69 -0
- data/lib/generators/reji/install/templates/db/migrate/add_reji_to_users.rb.erb +16 -0
- data/lib/generators/reji/install/templates/db/migrate/create_subscription_items.rb.erb +19 -0
- data/lib/generators/reji/install/templates/db/migrate/create_subscriptions.rb.erb +22 -0
- data/lib/generators/reji/install/templates/reji.rb +36 -0
- data/lib/reji.rb +75 -0
- data/lib/reji/billable.rb +13 -0
- data/lib/reji/concerns/interacts_with_payment_behavior.rb +33 -0
- data/lib/reji/concerns/manages_customer.rb +113 -0
- data/lib/reji/concerns/manages_invoices.rb +136 -0
- data/lib/reji/concerns/manages_payment_methods.rb +202 -0
- data/lib/reji/concerns/manages_subscriptions.rb +91 -0
- data/lib/reji/concerns/performs_charges.rb +36 -0
- data/lib/reji/concerns/prorates.rb +38 -0
- data/lib/reji/configuration.rb +59 -0
- data/lib/reji/engine.rb +4 -0
- data/lib/reji/errors.rb +66 -0
- data/lib/reji/invoice.rb +243 -0
- data/lib/reji/invoice_line_item.rb +98 -0
- data/lib/reji/payment.rb +61 -0
- data/lib/reji/payment_method.rb +32 -0
- data/lib/reji/subscription.rb +567 -0
- data/lib/reji/subscription_builder.rb +206 -0
- data/lib/reji/subscription_item.rb +97 -0
- data/lib/reji/tax.rb +48 -0
- data/lib/reji/version.rb +5 -0
- data/reji.gemspec +32 -0
- data/spec/dummy/app/models/user.rb +21 -0
- data/spec/dummy/application.rb +53 -0
- data/spec/dummy/config/database.yml +11 -0
- data/spec/dummy/db/schema.rb +40 -0
- data/spec/feature/charges_spec.rb +67 -0
- data/spec/feature/customer_spec.rb +23 -0
- data/spec/feature/invoices_spec.rb +73 -0
- data/spec/feature/multiplan_subscriptions_spec.rb +319 -0
- data/spec/feature/payment_methods_spec.rb +149 -0
- data/spec/feature/pending_updates_spec.rb +77 -0
- data/spec/feature/subscriptions_spec.rb +650 -0
- data/spec/feature/webhooks_spec.rb +162 -0
- data/spec/spec_helper.rb +27 -0
- data/spec/support/feature_helpers.rb +39 -0
- data/spec/unit/customer_spec.rb +54 -0
- data/spec/unit/invoice_line_item_spec.rb +72 -0
- data/spec/unit/invoice_spec.rb +192 -0
- data/spec/unit/payment_spec.rb +33 -0
- data/spec/unit/subscription_spec.rb +103 -0
- metadata +237 -0
data/Rakefile
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'bundler/gem_tasks'
|
6
|
+
|
7
|
+
require 'rake'
|
8
|
+
require 'rspec/core/rake_task'
|
9
|
+
|
10
|
+
namespace :dummy do
|
11
|
+
require_relative 'spec/dummy/application'
|
12
|
+
Dummy::Application.load_tasks
|
13
|
+
end
|
14
|
+
|
15
|
+
desc 'Run specs'
|
16
|
+
RSpec::Core::RakeTask.new('spec') do |task|
|
17
|
+
task.verbose = false
|
18
|
+
end
|
19
|
+
|
20
|
+
desc 'Run the specs and acceptance tests'
|
21
|
+
task default: %w(spec)
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reji
|
4
|
+
class PaymentController < ActionController::Base
|
5
|
+
before_action :verify_redirect_url
|
6
|
+
|
7
|
+
def show
|
8
|
+
@stripe_key = Reji.configuration.key
|
9
|
+
|
10
|
+
@payment = Reji::Payment.new(
|
11
|
+
Stripe::PaymentIntent.retrieve(
|
12
|
+
params[:id], Reji.stripe_options
|
13
|
+
)
|
14
|
+
)
|
15
|
+
|
16
|
+
@redirect = params[:redirect]
|
17
|
+
|
18
|
+
render template: 'payment'
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def verify_redirect_url
|
24
|
+
return if params[:redirect].blank?
|
25
|
+
|
26
|
+
url = URI(params[:redirect])
|
27
|
+
|
28
|
+
raise ActionController::Forbidden.new('Redirect host mismatch.') if url.host.blank? || url.host != URI(request.original_url).host
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reji
|
4
|
+
class WebhookController < ActionController::Base
|
5
|
+
before_action :verify_webhook_signature
|
6
|
+
|
7
|
+
def handle_webhook
|
8
|
+
payload = JSON.parse(request.body.read)
|
9
|
+
|
10
|
+
type = payload['type']
|
11
|
+
|
12
|
+
return self.missing_method if type.nil?
|
13
|
+
|
14
|
+
method = "handle_#{payload['type'].gsub('.', '_')}"
|
15
|
+
|
16
|
+
self.respond_to?(method, true) ?
|
17
|
+
self.send(method, payload) :
|
18
|
+
self.missing_method
|
19
|
+
end
|
20
|
+
|
21
|
+
protected
|
22
|
+
|
23
|
+
# Handle customer subscription updated.
|
24
|
+
def handle_customer_subscription_updated(payload)
|
25
|
+
user = self.get_user_by_stripe_id(payload.dig('data', 'object', 'customer'))
|
26
|
+
|
27
|
+
return self.success_method if user.nil?
|
28
|
+
|
29
|
+
data = payload.dig('data', 'object')
|
30
|
+
|
31
|
+
return self.success_method if data.nil?
|
32
|
+
|
33
|
+
user.subscriptions
|
34
|
+
.select { |subscription| subscription.stripe_id == data['id'] }
|
35
|
+
.each do |subscription|
|
36
|
+
if data['status'] == 'incomplete_expired'
|
37
|
+
subscription.items.destroy_all
|
38
|
+
subscription.destroy
|
39
|
+
|
40
|
+
return self.success_method
|
41
|
+
end
|
42
|
+
|
43
|
+
# Plan...
|
44
|
+
subscription.stripe_plan = data.dig('plan', 'id')
|
45
|
+
|
46
|
+
# Quantity...
|
47
|
+
subscription.quantity = data['quantity']
|
48
|
+
|
49
|
+
# Trial ending date...
|
50
|
+
unless data['trial_end'].nil?
|
51
|
+
if subscription.trial_ends_at.nil? || subscription.trial_ends_at.to_i != data['trial_end']
|
52
|
+
subscription.trial_ends_at = Time.at(data['trial_end'])
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Cancellation date...
|
57
|
+
unless data['cancel_at_period_end'].nil?
|
58
|
+
if data['cancel_at_period_end']
|
59
|
+
subscription.ends_at = subscription.on_trial ?
|
60
|
+
subscription.trial_ends_at :
|
61
|
+
Time.at(data['cancel_at_period_end'])
|
62
|
+
else
|
63
|
+
subscription.ends_at = nil
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Status...
|
68
|
+
unless data['status'].nil?
|
69
|
+
subscription.stripe_status = data['status']
|
70
|
+
end
|
71
|
+
|
72
|
+
subscription.save
|
73
|
+
|
74
|
+
# Update subscription items...
|
75
|
+
if data.key?('items')
|
76
|
+
plans = []
|
77
|
+
|
78
|
+
items = data.dig('items', 'data')
|
79
|
+
|
80
|
+
unless items.blank?
|
81
|
+
items.each do |item|
|
82
|
+
plans << item.dig('plan', 'id')
|
83
|
+
|
84
|
+
subscription_item = subscription.items.find_or_create_by({:stripe_id => item['id']}) do |subscription_item|
|
85
|
+
subscription_item.stripe_plan = item.dig('plan', 'id')
|
86
|
+
subscription_item.quantity = item['quantity']
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Delete items that aren't attached to the subscription anymore...
|
92
|
+
subscription.items.where('stripe_plan NOT IN (?)', plans).destroy_all
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
self.success_method
|
97
|
+
end
|
98
|
+
|
99
|
+
# Handle a cancelled customer from a Stripe subscription.
|
100
|
+
def handle_customer_subscription_deleted(payload)
|
101
|
+
user = self.get_user_by_stripe_id(payload.dig('data', 'object', 'customer'))
|
102
|
+
|
103
|
+
unless user.nil?
|
104
|
+
user.subscriptions
|
105
|
+
.select { |subscription| subscription.stripe_id == payload.dig('data', 'object', 'id') }
|
106
|
+
.each { |subscription| subscription.mark_as_cancelled }
|
107
|
+
end
|
108
|
+
|
109
|
+
self.success_method
|
110
|
+
end
|
111
|
+
|
112
|
+
# Handle customer updated.
|
113
|
+
def handle_customer_updated(payload)
|
114
|
+
user = self.get_user_by_stripe_id(payload.dig('data', 'object', 'id'))
|
115
|
+
|
116
|
+
user.update_default_payment_method_from_stripe unless user.nil?
|
117
|
+
|
118
|
+
self.success_method
|
119
|
+
end
|
120
|
+
|
121
|
+
# Handle deleted customer.
|
122
|
+
def handle_customer_deleted(payload)
|
123
|
+
user = self.get_user_by_stripe_id(payload.dig('data', 'object', 'id'))
|
124
|
+
|
125
|
+
unless user.nil?
|
126
|
+
user.subscriptions.each { |subscription| subscription.skip_trial.mark_as_cancelled }
|
127
|
+
|
128
|
+
user.update({
|
129
|
+
:stripe_id => nil,
|
130
|
+
:trial_ends_at => nil,
|
131
|
+
:card_brand => nil,
|
132
|
+
:card_last_four => nil,
|
133
|
+
})
|
134
|
+
end
|
135
|
+
|
136
|
+
self.success_method
|
137
|
+
end
|
138
|
+
|
139
|
+
# Get the billable entity instance by Stripe ID.
|
140
|
+
def get_user_by_stripe_id(stripe_id)
|
141
|
+
Reji.find_billable(stripe_id)
|
142
|
+
end
|
143
|
+
|
144
|
+
# Handle successful calls on the controller.
|
145
|
+
def success_method
|
146
|
+
render plain: 'Webhook Handled', status: 200
|
147
|
+
end
|
148
|
+
|
149
|
+
# Handle calls to missing methods on the controller.
|
150
|
+
def missing_method
|
151
|
+
head :ok
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
def verify_webhook_signature
|
157
|
+
return if Reji.configuration.webhook[:secret].blank?
|
158
|
+
|
159
|
+
begin
|
160
|
+
Stripe::Webhook.construct_event(
|
161
|
+
request.body.read,
|
162
|
+
request.env['HTTP_STRIPE_SIGNATURE'],
|
163
|
+
Reji.configuration.webhook[:secret],
|
164
|
+
)
|
165
|
+
rescue Stripe::SignatureVerificationError => e
|
166
|
+
raise AccessDeniedHttpError.new(e.message)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
@@ -0,0 +1,228 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="<%= I18n.locale %>" class="h-full">
|
3
|
+
<head>
|
4
|
+
<meta charset="utf-8">
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
6
|
+
|
7
|
+
<title>Payment Confirmation</title>
|
8
|
+
|
9
|
+
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
10
|
+
|
11
|
+
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.min.js"></script>
|
12
|
+
<script src="https://js.stripe.com/v3"></script>
|
13
|
+
</head>
|
14
|
+
<body class="font-sans text-gray-600 bg-gray-200 leading-normal p-4 h-full">
|
15
|
+
<div id="app" class="h-full md:flex md:justify-center md:items-center">
|
16
|
+
<div class="w-full max-w-lg">
|
17
|
+
<!-- Status Messages -->
|
18
|
+
<p class="flex items-center mb-4 bg-red-100 border border-red-200 px-5 py-2 rounded-lg text-red-500" v-if="errorMessage">
|
19
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="flex-shrink-0 w-6 h-6">
|
20
|
+
<path class="fill-current text-red-300" d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20z"/>
|
21
|
+
<path class="fill-current text-red-500" d="M12 18a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm1-5.9c-.13 1.2-1.88 1.2-2 0l-.5-5a1 1 0 0 1 1-1.1h1a1 1 0 0 1 1 1.1l-.5 5z"/>
|
22
|
+
</svg>
|
23
|
+
|
24
|
+
<span class="ml-3">@{{ errorMessage }}</span>
|
25
|
+
</p>
|
26
|
+
|
27
|
+
<p class="flex items-center mb-4 bg-green-100 border border-green-200 px-5 py-4 rounded-lg text-green-700" v-if="paymentProcessed && successMessage">
|
28
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="flex-shrink-0 w-6 h-6">
|
29
|
+
<circle cx="12" cy="12" r="10" class="fill-current text-green-300"/>
|
30
|
+
<path class="fill-current text-green-500" d="M10 14.59l6.3-6.3a1 1 0 0 1 1.4 1.42l-7 7a1 1 0 0 1-1.4 0l-3-3a1 1 0 0 1 1.4-1.42l2.3 2.3z"/>
|
31
|
+
</svg>
|
32
|
+
|
33
|
+
<span class="ml-3">@{{ successMessage }}</span>
|
34
|
+
</p>
|
35
|
+
|
36
|
+
<div class="bg-white rounded-lg shadow-xl p-4 sm:py-6 sm:px-10 mb-5">
|
37
|
+
<% if @payment.is_succeeded %>
|
38
|
+
<h1 class="text-xl mt-2 mb-4 text-gray-700">
|
39
|
+
Payment Successful
|
40
|
+
</h1>
|
41
|
+
|
42
|
+
<p class="mb-6">
|
43
|
+
This payment was already successfully confirmed.
|
44
|
+
</p>
|
45
|
+
<% elsif @payment.is_cancelleed %>
|
46
|
+
<h1 class="text-xl mt-2 mb-4 text-gray-700">
|
47
|
+
Payment Cancelled
|
48
|
+
</h1>
|
49
|
+
|
50
|
+
<p class="mb-6">
|
51
|
+
This payment was cancelled.
|
52
|
+
</p>
|
53
|
+
<% else %>
|
54
|
+
<div id="payment-elements" v-if="! paymentProcessed">
|
55
|
+
<!-- Payment Method Form -->
|
56
|
+
<div v-show="requiresPaymentMethod">
|
57
|
+
<!-- Instructions -->
|
58
|
+
<h1 class="text-xl mt-2 mb-4 text-gray-700">
|
59
|
+
Confirm your <%= @payment.amount %> payment
|
60
|
+
</h1>
|
61
|
+
|
62
|
+
<p class="mb-6">
|
63
|
+
Extra confirmation is needed to process your payment. Please confirm your payment by filling out your payment details below.
|
64
|
+
</p>
|
65
|
+
|
66
|
+
<!-- Name -->
|
67
|
+
<label for="cardholder-name" class="inline-block text-sm text-gray-700 font-semibold mb-2">
|
68
|
+
Full name
|
69
|
+
</label>
|
70
|
+
|
71
|
+
<input
|
72
|
+
id="cardholder-name"
|
73
|
+
type="text" placeholder="Jane Doe"
|
74
|
+
required
|
75
|
+
class="inline-block bg-gray-200 border border-gray-400 rounded-lg w-full px-4 py-3 mb-3 focus:outline-none"
|
76
|
+
v-model="name"
|
77
|
+
/>
|
78
|
+
|
79
|
+
<!-- Card -->
|
80
|
+
<label for="card-element" class="inline-block text-sm text-gray-700 font-semibold mb-2">
|
81
|
+
Card
|
82
|
+
</label>
|
83
|
+
|
84
|
+
<div id="card-element" class="bg-gray-200 border border-gray-400 rounded-lg p-4 mb-6"></div>
|
85
|
+
|
86
|
+
<!-- Pay Button -->
|
87
|
+
<button
|
88
|
+
id="card-button"
|
89
|
+
class="inline-block w-full px-4 py-3 mb-4 text-white rounded-lg hover:bg-blue-500"
|
90
|
+
:class="{ 'bg-blue-400': paymentProcessing, 'bg-blue-600': ! paymentProcessing }"
|
91
|
+
@click="addPaymentMethod"
|
92
|
+
:disabled="paymentProcessing"
|
93
|
+
>
|
94
|
+
Pay <%= @payment.amount %>
|
95
|
+
</button>
|
96
|
+
</div>
|
97
|
+
</div>
|
98
|
+
<% end %>
|
99
|
+
|
100
|
+
<button @click="goBack" ref="goBackButton" data-redirect="<%= @redirect || '/' %>"
|
101
|
+
class="inline-block w-full px-4 py-3 bg-gray-200 hover:bg-gray-300 text-center text-gray-700 rounded-lg">
|
102
|
+
Go back
|
103
|
+
</button>
|
104
|
+
</div>
|
105
|
+
|
106
|
+
<p class="text-center text-gray-500 text-sm">
|
107
|
+
© <%= Time.now.year %>. All rights reserved.
|
108
|
+
</p>
|
109
|
+
</div>
|
110
|
+
</div>
|
111
|
+
|
112
|
+
<script>
|
113
|
+
window.stripe = Stripe('<%= @stripe_key %>');
|
114
|
+
|
115
|
+
var app = new Vue({
|
116
|
+
el: '#app',
|
117
|
+
|
118
|
+
data: {
|
119
|
+
name: '',
|
120
|
+
cardElement: null,
|
121
|
+
paymentProcessing: false,
|
122
|
+
paymentProcessed: false,
|
123
|
+
requiresPaymentMethod: <%= @payment.requires_payment_method ? 'true' : 'false' %>,
|
124
|
+
requiresAction: <%= @payment.requires_action ? 'true' : 'false' %>,
|
125
|
+
successMessage: '',
|
126
|
+
errorMessage: ''
|
127
|
+
},
|
128
|
+
|
129
|
+
<% if ! @payment.is_succeeded && ! @payment.is_cancelleed && ! @payment.requires_action %>
|
130
|
+
mounted: function () {
|
131
|
+
this.configureStripe();
|
132
|
+
},
|
133
|
+
<% end %>
|
134
|
+
|
135
|
+
methods: {
|
136
|
+
addPaymentMethod: function () {
|
137
|
+
var self = this;
|
138
|
+
|
139
|
+
this.paymentProcessing = true;
|
140
|
+
this.paymentProcessed = false;
|
141
|
+
this.successMessage = '';
|
142
|
+
this.errorMessage = '';
|
143
|
+
|
144
|
+
stripe.confirmCardPayment(
|
145
|
+
'<%= @payment.client_secret %>', {
|
146
|
+
payment_method: {
|
147
|
+
card: this.cardElement,
|
148
|
+
billing_details: { name: this.name }
|
149
|
+
}
|
150
|
+
}
|
151
|
+
).then(function (result) {
|
152
|
+
self.paymentProcessing = false;
|
153
|
+
|
154
|
+
if (result.error) {
|
155
|
+
if (result.error.code === 'parameter_invalid_empty' &&
|
156
|
+
result.error.param === 'payment_method_data[billing_details][name]') {
|
157
|
+
self.errorMessage = 'Please provide your name.';
|
158
|
+
} else {
|
159
|
+
self.errorMessage = result.error.message;
|
160
|
+
}
|
161
|
+
} else {
|
162
|
+
self.paymentProcessed = true;
|
163
|
+
|
164
|
+
self.successMessage = 'The payment was successful.';
|
165
|
+
}
|
166
|
+
});
|
167
|
+
},
|
168
|
+
|
169
|
+
confirmPaymentMethod: function () {
|
170
|
+
var self = this;
|
171
|
+
|
172
|
+
this.paymentProcessing = true;
|
173
|
+
this.paymentProcessed = false;
|
174
|
+
this.successMessage = '';
|
175
|
+
this.errorMessage = '';
|
176
|
+
|
177
|
+
stripe.confirmCardPayment(
|
178
|
+
'<%= @payment.client_secret %>', {
|
179
|
+
payment_method: '<%= @payment.payment_method %>'
|
180
|
+
}
|
181
|
+
).then(function (result) {
|
182
|
+
self.paymentProcessing = false;
|
183
|
+
|
184
|
+
if (result.error) {
|
185
|
+
self.errorMessage = result.error.message;
|
186
|
+
|
187
|
+
if (result.error.code === 'payment_intent_authentication_failure') {
|
188
|
+
self.requestPaymentMethod();
|
189
|
+
}
|
190
|
+
} else {
|
191
|
+
self.paymentProcessed = true;
|
192
|
+
|
193
|
+
self.successMessage = 'The payment was successful.';
|
194
|
+
}
|
195
|
+
});
|
196
|
+
},
|
197
|
+
|
198
|
+
requestPaymentMethod: function () {
|
199
|
+
this.configureStripe();
|
200
|
+
|
201
|
+
this.requiresPaymentMethod = true;
|
202
|
+
this.requiresAction = false;
|
203
|
+
},
|
204
|
+
|
205
|
+
configureStripe: function () {
|
206
|
+
const elements = stripe.elements();
|
207
|
+
|
208
|
+
this.cardElement = elements.create('card');
|
209
|
+
this.cardElement.mount('#card-element');
|
210
|
+
},
|
211
|
+
|
212
|
+
goBack: function () {
|
213
|
+
var self = this;
|
214
|
+
var button = this.$refs.goBackButton;
|
215
|
+
var redirect = button.dataset.redirect;
|
216
|
+
|
217
|
+
if (self.successMessage || self.errorMessage) {
|
218
|
+
redirect.searchParams.append('message', self.successMessage ? self.successMessage : self.errorMessage);
|
219
|
+
redirect.searchParams.append('success', !! self.successMessage);
|
220
|
+
}
|
221
|
+
|
222
|
+
window.location.href = redirect;
|
223
|
+
},
|
224
|
+
},
|
225
|
+
})
|
226
|
+
</script>
|
227
|
+
</body>
|
228
|
+
</html>
|